Перевести тестовые фейки на моки 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 name: ci
# Прогоняем CI в двух случаях:
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
# Обычные коммиты в ветки без открытого МР CI не запускают.
on: on:
push: push:
branches:
- main
pull_request: pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch: workflow_dispatch:
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
# Завершённые (успех/фейл) прогоны не трогаются.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-test: build-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
/// </summary> /// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param> /// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</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( public DiscoveryBanGuard(
IDiscoveryStore store, IDiscoveryStore store,
ISettingsStore settings, ISettingsStore settings,
@@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore(); var audit = new TestAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), 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); 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() public async Task RunCycle_IsIdempotent()
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore(); var audit = new TestAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
DataRetentionScheduler scheduler = new( DataRetentionScheduler scheduler = new(
@@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests
// counters: Хелпер счётчиков (окна посеяны сценарием). // counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
FakeAuditLogStore audit, TestAuditLogStore audit,
FakeTenantLimitStore limits, TestTenantLimitStore limits,
TestRateLimitCounterStore counters) TestRateLimitCounterStore counters)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit); services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store); services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore(); var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore(); var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new TestSettingsStore();
var gatewayA = new TestDiscoveryGateway(); var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway(); var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA); PreloadDiscoveryEvalOff(settingsA);
@@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants.Repository); services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton<IMlClient>(new FakeMlClient()); services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiTools>(_ => TestAiTools.New()); services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store); 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); services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule(); services.AddDiscoveryModule();
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
} }
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам). // ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings) private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
{ {
settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false");
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()), services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel => async channel =>
{ {
PushSourceReply first = await PushAsync(channel, TenantA); PushSourceReply first = await PushAsync(channel, TenantA);
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()), services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel => async channel =>
{ {
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
{ {
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
var store = new FakeMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a"); SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
tenantContext, tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store }); new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider); MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None);
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха. // 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
Assert.Equal(3, service.TrainCalls); Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); 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 не переживает проход Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
}); });
} }
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
service.TrainUnavailable = true; service.TrainUnavailable = true;
var store = new FakeMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a"); SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
tenantContext, tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store }); new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider); MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None); 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); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(2, service.TrainCalls); 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); Assert.False(tenantContext.HasTenant);
}); });
} }
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
{ {
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
var storeA = new FakeMlLearningStore(); var storeA = new TestMlLearningStore();
SeedRows(storeA, count: 12, prefix: "a"); SeedRows(storeA, count: 12, prefix: "a");
var storeB = new FakeMlLearningStore(); var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b"); SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext, tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB }); new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider); MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None);
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены. // Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
Assert.Equal(3, service.TrainCalls); Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); 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 storeA.Store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None)); Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant); Assert.False(tenantContext.HasTenant);
}); });
} }
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
{ {
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
var store = new FakeMlLearningStore(); var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a"); SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
port, port,
new TestTenantRepository(Tenant(TenantA)), new TestTenantRepository(Tenant(TenantA)),
tenantContext, tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store }); new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider); MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(10, service.TrainCalls); 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, int port,
TestTenantRepository tenants, TestTenantRepository tenants,
TenantContext tenantContext, TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant) Dictionary<Guid, TestMlLearningStore> storesByTenant)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
@@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>()); services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>()); services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]); services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore()); services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>(); services.AddScoped<TokenUsageRecorder>();
services.AddLogging(); services.AddLogging();
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
// count: Число строк. // count: Число строк.
// prefix: Префикс id (различает тенантов теста). // prefix: Префикс id (различает тенантов теста).
private static void SeedRows( private static void SeedRows(
FakeMlLearningStore store, TestMlLearningStore store,
int count, int count,
string prefix) string prefix)
{ {
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
IConfiguration configuration = new ConfigurationBuilder() IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values) .AddInMemoryCollection(values)
.Build(); .Build();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var logger = new ListLogger(); var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService): // Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков. // провайдер собирается с реальной регистрацией модуля поверх фейков.
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddScoped<IOperatorAuthStore>(_ => store); services.AddScoped<IOperatorAuthStore>(_ => store.Store);
services.AddScoped<IPasswordHasher>(_ => passwordHasher); services.AddScoped<IPasswordHasher>(_ => passwordHasher);
services.AddScoped<OperatorBootstrapService>(); services.AddScoped<OperatorBootstrapService>();
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания. // Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
// Контекст теста bootstrap-шага. // Контекст теста bootstrap-шага.
private sealed record Context( private sealed record Context(
OperatorBootstrapHostedService Hosted, OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store, TestOperatorAuthStore Store,
IPasswordHasher PasswordHasher, IPasswordHasher PasswordHasher,
ListLogger Logs); ListLogger Logs);
@@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests
[Fact] [Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
{ {
var pipelineByTenant = new Dictionary<string, FakePipelineStore> var pipelineByTenant = new Dictionary<string, TestPipelineStore>
{ {
[TenantA.ToString("N")] = new FakePipelineStore(), [TenantA.ToString("N")] = new TestPipelineStore(),
[TenantB.ToString("N")] = new FakePipelineStore(), [TenantB.ToString("N")] = new TestPipelineStore(),
}; };
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); 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(), [TenantA.ToString("N")] = new TestMlLearningStore(),
[TenantB.ToString("N")] = new FakeMlLearningStore(), [TenantB.ToString("N")] = new TestMlLearningStore(),
}; };
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact] [Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant() public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{ {
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow; DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB, TenantC), PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC), OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC), TenantRecords(TenantA, TenantB, TenantC),
limits); limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact] [Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{ {
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
limits.FailStateReads.Add(TenantA); limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB), PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB), OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB), TenantRecords(TenantA, TenantB),
limits); limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests
public async Task CollectAsync_NoTenants_ReturnsZeros() public async Task CollectAsync_NoTenants_ReturnsZeros()
{ {
RuntimeDepthsCollector collector = Build( RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(), new Dictionary<string, TestPipelineStore>(),
new Dictionary<string, FakeMlLearningStore>(), new Dictionary<string, TestMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>()); tenants: Array.Empty<TenantRecordDto>());
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests
Status = PipelineQueueStatuses.New, Status = PipelineQueueStatuses.New,
}; };
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants) private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore()); => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants) private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore()); => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants) private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants => tenants
@@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build( private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant, IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant, IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null, IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null) ITenantLimitStore? limitStore = null)
{ {
@@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).ToArray()).Repository); }).ToArray()).Repository);
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]); services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)].Store);
services.AddScoped<IMlClient>(_ => new FakeMlClient()); services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
services.AddScoped<PipelineIngestService>(); services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>(); services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]); services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore()); services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
ServiceProvider provider = services.BuildServiceProvider(); ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector( return new RuntimeDepthsCollector(
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() 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_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher). // Собирает сервис на фейках (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 store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway(); var gateway = new TestTelegramGateway();
var cipher = TestCiphers.New(); var cipher = TestCiphers.New();
var globalSettings = new TestGlobalSettingsStore(); var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher); var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance); var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys); var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
return (service, store, settings, gateway, cipher, globalSettings); return (service, store, settings, gateway, cipher, globalSettings);
} }
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary> /// </summary>
public sealed class BudgetedAiClassifierTests public sealed class BudgetedAiClassifierTests
{ {
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита. // Guid того же тенанта — ключ строки лимита.
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
Context ctx = Create( Context ctx = Create(
limits => limits.Preload( limits => limits.Preload(
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); 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к$"; const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
@@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests
private sealed record Context( private sealed record Context(
IAiClassifier Decorator, IAiClassifier Decorator,
TestAiClassifier Paid, TestAiClassifier Paid,
FakeSettingsStore Settings); TestSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false). // (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста. // Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null) private static Context Create(Action<TestTenantLimitStore>? configure = null)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new TestAiClassifier(); var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier( IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier, paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)), new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiClassifier>.Instance); NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings); return new Context(decorator, paid, settings);
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary> /// </summary>
public sealed class BudgetedAiToolsTests public sealed class BudgetedAiToolsTests
{ {
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита. // Guid того же тенанта — ключ строки лимита.
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
// AddDealIntegrations при UseLocal=false). // AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // 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); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = TestAiTools.New(); var paid = TestAiTools.New();
IAiTools decorator = new BudgetedAiTools( IAiTools decorator = new BudgetedAiTools(
paid, paid,
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiTools>.Instance); NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid); return new Context(decorator, paid);
@@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
[Fact] [Fact]
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits() public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
{ {
(CardComposer composer, FakeKanjStore store, _) = Create(); (CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" }); store.SeedBoard(new ContainerDto { Id = "b_py" });
CardSnapshot snapshot = await composer.BuildAsync( CardSnapshot snapshot = await composer.BuildAsync(
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
[Fact] [Fact]
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox() public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
{ {
(CardComposer composer, FakeKanjStore store, _) = Create(); (CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" })); store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
CardSnapshot snapshot = await composer.BuildAsync( CardSnapshot snapshot = await composer.BuildAsync(
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
[Fact] [Fact]
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits() public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
{ {
(CardComposer composer, FakeKanjStore store, _) = Create(); (CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_py", new[] { "python" })); store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
CardSnapshot snapshot = await composer.BuildAsync( CardSnapshot snapshot = await composer.BuildAsync(
@@ -193,7 +192,7 @@ public sealed class CardComposerTests
[Fact] [Fact]
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty() 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 settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349
CardSnapshot snapshot = await composer.BuildAsync( CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки). // Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create() private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
{ {
var store = new FakeKanjStore(); var store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new CardComposer(store, settings), store, settings); 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.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -17,7 +16,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc() 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_1", "inbox", receivedAtMs: 100));
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300)); store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
@@ -30,7 +29,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards() 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_1", "inbox"));
store.SeedCard(Card("l_2", KanbanColumns.Trash)); store.SeedCard(Card("l_2", KanbanColumns.Trash));
@@ -43,7 +42,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task GetCard_Existing_ReturnsCard() 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")); store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None); CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
@@ -66,7 +65,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$.")); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
@@ -84,7 +83,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToBoard_WritesJournalAndPush() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -105,7 +104,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox")); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
@@ -124,7 +123,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToUnknownBoard_Returns400Text() 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-разработчик")); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
@@ -148,7 +147,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_CardMissing_ReturnsNullLead() 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")); store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
@@ -162,7 +161,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true)); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
@@ -179,7 +178,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent() 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.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст")); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
@@ -195,7 +194,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel() 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_java", Rules(stack: new[] { "java" })));
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox")); store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
@@ -214,7 +213,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " ")); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
@@ -228,7 +227,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush() 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.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox")); store.SeedCard(Card("l_1", "inbox"));
@@ -241,7 +240,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget() public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_mid", Rules( store.SeedBoard(Board("b_mid", Rules(
grade: new[] { "middle" }, grade: new[] { "middle" },
budget: new BudgetRangeDto(1000, 3000, "USD")))); budget: new BudgetRangeDto(1000, 3000, "USD"))));
@@ -263,7 +262,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing() 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.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
@@ -279,7 +278,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing() 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.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
@@ -296,7 +295,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() 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)); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -318,7 +317,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush() 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: "Текст")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -331,7 +330,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush() 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")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -346,7 +345,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Trash_CardMissing_ReturnsNull() 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); CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
@@ -358,7 +357,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
@@ -383,7 +382,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Restore_FromArchive_NoPush() 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.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
@@ -399,7 +398,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox() 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")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -414,7 +413,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox() 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")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -439,7 +438,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, store.SeedCard(Card("l_1", KanbanColumns.Trash,
sourceMsg: "Нужен Python-разработчик", sourceMsg: "Нужен Python-разработчик",
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")])); comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task DeleteForever_JournalRowsSurviveCardDeletion() 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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task ClearCol_Trash_ReturnsClearedCount() 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_1", KanbanColumns.Trash));
store.SeedCard(Card("l_2", KanbanColumns.Trash)); store.SeedCard(Card("l_2", KanbanColumns.Trash));
store.SeedCard(Card("l_3", KanbanColumns.Archive)); store.SeedCard(Card("l_3", KanbanColumns.Archive));
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task ClearCol_Board_Returns400Text() public async Task ClearCol_Board_Returns400Text()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py")); store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "b_py")); store.SeedCard(Card("l_1", "b_py"));
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task MarkSeen_ById_OnlyThatCard() 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_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true));
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task MarkSeen_ByCol_OnlyColumnCards() 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_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true));
store.SeedCard(Card("l_3", "b_py", isNew: true)); store.SeedCard(Card("l_3", "b_py", isNew: true));
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task MarkSeen_All_WithoutParameters() 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_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true)); store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox")); store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None); AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal() public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox")); store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None); AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Counts_ColumnsNewAndMlStats() 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_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox")); store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py")); store.SeedCard(Card("l_3", "b_py"));
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик")); store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None); IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Search_DelegatesToStoreWithQueryAndLimit() 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)); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None); IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc() 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_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200)); store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100)); store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
[Fact] [Fact]
public async Task Search_LimitIsTwelve() public async Task Search_LimitIsTwelve()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
for (int i = 0; i < 15; i++) for (int i = 0; i < 15; i++)
{ {
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var ml = new FakeMlClient(); var ml = new TestMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml); return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
} }
// Доска с правилами (ContainerRulesDto) либо без них. // Доска с правилами (ContainerRulesDto) либо без них.
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() 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"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые). // Собирает сервис на фейках (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 store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway(); 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 + флаги/хвост каталога). // Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact] [Fact]
public async Task EvaluateMessage_MlSpam_NotFit() 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( ml.Predict = new MlPredictResultDto(
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9, Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
Terms: Array.Empty<string>(), Type: null); Terms: Array.Empty<string>(), Type: null);
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact] [Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() 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, "тема совпадает")); ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( 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); => 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.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient(); var ml = new TestMlClient();
var ai = TestAiTools.New(); 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). // Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния. // Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture( private sealed record Fixture(
TestDiscoveryStore Store, TestDiscoveryStore Store,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestDiscoveryGateway Gateway, TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer, IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker, DiscoveryWorkerService Worker,
@@ -429,21 +429,21 @@ public sealed class DiscoveryWorkerServiceTests
private static Fixture Create() private static Fixture Create()
{ {
var store = new TestDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient(); var ml = new TestMlClient();
var ai = TestAiTools.New(); var ai = TestAiTools.New();
var log = new DiscoveryLogService(store.Store); var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store); var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist); var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings); var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
var banGuard = new DiscoveryBanGuard(store.Store, settings); var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
var pacer = Substitute.For<IDiscoveryPacer>(); var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway(); var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService( 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); 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("python разработка");
reply.Keywords.Add("telegram каналы"); reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply; service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new(); TestSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New(); ISecretCipher cipher = TestCiphers.New();
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits); IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); 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) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.KeywordsUnavailable = true; 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); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests
Reason = "другая сфера", Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
}; };
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.FitUnavailable = true; service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests
// Возвращает: Экземпляр GrpcAiTools. // Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools( private static GrpcAiTools CreateTools(
int port, int port,
FakeSettingsStore settings, TestSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
FakeTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
{ {
limits ??= new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiTools( return new GrpcAiTools(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, cipher), new AiProviderConfigBuilder(settings.Store, cipher),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance); NullLogger<GrpcAiTools>.Instance);
} }
} }
@@ -110,10 +110,10 @@ public sealed class GrpcMlClientTests
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, 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.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3"); settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 }; var learning = new TestMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning); IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); 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) => 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_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
Assert.True(reset.Ok); Assert.True(reset.Ok);
Assert.Null(reset.Error); 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); Assert.Equal(1, service.ResetCalls);
_ = await client.StatusAsync(CancellationToken.None); _ = await client.StatusAsync(CancellationToken.None);
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" }; service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok); Assert.False(reset.Ok);
Assert.Equal("не удалось пересоздать файл модели", reset.Error); 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) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
service.ResetUnavailable = true; service.ResetUnavailable = true;
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0); learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok); Assert.False(reset.Ok);
Assert.Equal("ML-сервис недоступен", reset.Error); 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) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
IMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
@@ -309,22 +309,24 @@ public sealed class GrpcMlClientTests
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient( private static GrpcMlClient CreateClient(
int port, int port,
FakeSettingsStore? settings = null, TestSettingsStore? settings = null,
FakeMlLearningStore? learning = null, TestMlLearningStore? learning = null,
MlStatusCache? cache = null) MlStatusCache? cache = null)
{ {
settings ??= new TestSettingsStore();
learning ??= new TestMlLearningStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient( return new GrpcMlClient(
tenantContext, tenantContext,
settings ?? new FakeSettingsStore(), settings.Store,
learning ?? new FakeMlLearningStore(), learning.Store,
new MlGrpcConnection(options), new MlGrpcConnection(options),
cache ?? new MlStatusCache(), cache ?? new MlStatusCache(),
new TokenUsageRecorder( new TokenUsageRecorder(
settings ?? new FakeSettingsStore(), settings.Store,
new FakeTenantLimitStore(), new TestTenantLimitStore().Store,
tenantContext, tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance); NullLogger<GrpcMlClient>.Instance);
@@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
@@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New()); services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore()); services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore()); services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore()); services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<LocalFieldsParser>(); services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>(); services.AddScoped<AiClassifyContextBuilder>();
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
{ {
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер. // Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор). // Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings)); var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
return (settings, classifier); return (settings, classifier);
} }
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
[Fact] [Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed() public async Task Classify_CustomHireMarkersOverride_AreUsed()
{ {
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); (TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер. // Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]"); settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests
[Fact] [Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim() public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{ {
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); (PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01"; 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( AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота", title: "Python-разработчик на бота",
budget: new AiBudgetDto(2000, 2000, "USD"), 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(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); 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] [Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn() 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" }); store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01"; const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
CardDto card = await writer.CreateCardAsync( CardDto card = await writer.CreateCardAsync(
Parsed(title: "Python-разработчик", board: "b_py"), Parsed(title: "Python-разработчик", board: "b_py"),
@@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests
[Fact] [Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup() public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{ {
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); (PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта) store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01"; const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None)); () => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
@@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null). // Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
Assert.Null(pipelineStore.DedupLeadId(hash)); 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 store = new TestKanjStore();
var pipelineStore = new FakePipelineStore(); var pipelineStore = new TestPipelineStore();
var composer = new CardComposer(store, new FakeSettingsStore()); var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore); 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.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc; using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
@@ -34,7 +33,7 @@ public sealed class PipelineWorkerGrpcAiTests
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). // Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact] [Fact]
@@ -127,7 +126,7 @@ public sealed class PipelineWorkerGrpcAiTests
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, 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); limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
Context ctx = CreateContext(port, limits, budgeted: true); Context ctx = CreateContext(port, limits, budgeted: true);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
@@ -154,49 +153,49 @@ public sealed class PipelineWorkerGrpcAiTests
private sealed record Context( private sealed record Context(
PipelineWorkerService Worker, PipelineWorkerService Worker,
FakePipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, TestKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
FakeTenantLimitStore Limits); TestTenantLimitStore Limits);
private static Context CreateContext( private static Context CreateContext(
int port, int port,
FakeTenantLimitStore? limits = null, TestTenantLimitStore? limits = null,
bool budgeted = false) bool budgeted = false)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var pipelineStore = new FakePipelineStore(); var pipelineStore = new TestPipelineStore();
var kanjStore = new FakeKanjStore(); var kanjStore = new TestKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection( var connection = new AiGrpcConnection(
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
limits ??= new FakeTenantLimitStore(); limits ??= new TestTenantLimitStore();
var grpcClassifier = new GrpcAiClassifier( var grpcClassifier = new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, TestCiphers.New()), new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings, kanjStore), new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier( ? new BudgetedAiClassifier(
grpcClassifier, grpcClassifier,
new LocalAiClassifier(fieldsParser), new LocalAiClassifier(fieldsParser),
limits, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiClassifier>.Instance) NullLogger<BudgetedAiClassifier>.Instance)
: grpcClassifier; : grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore)); var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer); var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
var worker = new PipelineWorkerService( 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); 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.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
[Fact] [Fact]
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
{ {
(CardMover mover, FakeKanjStore store) = Create(); (CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto store.SeedCard(new CardDto
{ {
Id = "c_1", Id = "c_1",
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
[Fact] [Fact]
public async Task Move_ToBoard_RoutesToDashboard() 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.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен 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] [Fact]
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() 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 = "Текст" } }); 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); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
Assert.False(result.Exists); Assert.False(result.Exists);
} }
private static (CardMover Mover, FakeKanjStore Store) Create() private static (CardMover Mover, TestKanjStore Store) Create()
{ {
var store = new FakeKanjStore(); var store = new TestKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage); var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store); return (new CardMover(cardsService), store);
} }
} }
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
TestDiscoveryStore yesterdayStore = new(() => Now); TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now); var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
for (int i = 0; i < 49; i++) for (int i = 0; i < 49; i++)
{ {
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay() public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None); await guard.NoteFloodAsync(CancellationToken.None);
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task FloodYesterday_DoesNotBlockToday() public async Task FloodYesterday_DoesNotBlockToday()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString()); settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None)); Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task GlobalPaused_BlocksAutoJoins() public async Task GlobalPaused_BlocksAutoJoins()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true"); settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None)); Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored() public async Task CustomJoinLimit_AppliedWhenSettingStored()
{ {
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2"); settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
} }
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). // Создаёт связку гард + фейки на фиксированных «часах» (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 store = new TestDiscoveryStore(() => Now);
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now); var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
return (guard, store, settings); return (guard, store, settings);
} }
} }
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_CustomThresholdAndSample_Respected() 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("discEvalThreshold", "60");
settings.Preload("discEvalSample", "20"); settings.Preload("discEvalSample", "20");
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
} }
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create() private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{ {
var store = new TestDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings); return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
} }
// Draft задачи с дефолтными полями (name/planJoins переопределяются). // 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.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -24,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildFilterPrompt_FillsDomainAndKeywords() 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.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); 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); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -38,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint() 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}")); 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); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -52,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet() 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.AiPrompt, Json("Классифицируй {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}.")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); 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); string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
@@ -69,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase() public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
@@ -82,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules() public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out TestKanjStore kanj);
kanj.SeedBoard(new ContainerDto kanj.SeedBoard(new ContainerDto
{ {
Id = "b_py", Id = "b_py",
@@ -113,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests
Order = 1, Order = 1,
}); });
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 }); 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); string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
@@ -128,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst() 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_1", "b_py", "Пример: middle python (старый)");
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)"); 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); string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
@@ -144,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints() public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string text = new string('д', 6000); string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
@@ -158,10 +158,10 @@ public sealed class AiClassifyContextBuilderTests
// Создаёт контекст: пустое KV + пустой канбан-фейк. // Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров). // kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта. // Возвращает: KV-хранилище тенанта.
private static FakeSettingsStore Context(out FakeKanjStore kanj) private static TestSettingsStore Context(out TestKanjStore kanj)
{ {
kanj = new FakeKanjStore(); kanj = new TestKanjStore();
return new FakeSettingsStore(); return new TestSettingsStore();
} }
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера). // Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
@@ -170,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests
// board: Колонка-назначение (доска). // board: Колонка-назначение (доска).
// text: Исходный текст карточки (source_msg). // text: Исходный текст карточки (source_msg).
private static void SeedCardWithMove( private static void SeedCardWithMove(
FakeKanjStore kanj, TestKanjStore kanj,
string cardId, string cardId,
string board, string board,
string text) string text)
{ {
kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = 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.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() 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: "Отложенный бот")); store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
@@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Set_Enabled_SetsReminderAndReturnsCard() 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)); store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
long atMs = NowMs() + DayMs; long atMs = NowMs() + DayMs;
@@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Set_StageNotHold_Allowed() public async Task Set_StageNotHold_Allowed()
{ {
(CardsService service, FakeKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "В работе")); store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
long atMs = NowMs() + DayMs; long atMs = NowMs() + DayMs;
@@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue() 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) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
@@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Clear_RemindersDisabled_StillClears() 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) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
@@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Snooze_MovesReminderToNowPlus24h() 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) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
long beforeMs = NowMs(); long beforeMs = NowMs();
@@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Snooze_RemindersDisabled_StillSnoozes() 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) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
@@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
{ {
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
long pastMs = NowMs() - 1; long pastMs = NowMs() - 1;
store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) });
store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) });
@@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() 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_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_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) });
store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); store.SeedCard(Card("c_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))); due.Select(item => (item.Id, item.Title)));
Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
// «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). // «Выстрелившие» помечены 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
if (!remindersEnabled) if (!remindersEnabled)
{ {
settings.Preload(SettingsKeys.RemindersEnabled, "false"); 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) — для посева напоминаний в прошлом/будущем. // Текущее время в 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.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -19,15 +18,15 @@ public sealed class MlReviewServiceTests
private const string Dialog = "d_1"; private const string Dialog = "d_1";
private static MlReviewService Create( private static MlReviewService Create(
FakePipelineStore pipeline, TestPipelineStore pipeline,
FakeKanjStore kanj, TestKanjStore kanj,
FakeMlClient ml, TestMlClient ml,
out CardsService cards) out CardsService cards)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage); cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline, kanj, cards, processing, ml); return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client);
} }
private static QueueItemDto QueueRow( private static QueueItemDto QueueRow(
@@ -73,12 +72,12 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "из очереди")); pipeline.SeedQueue(QueueRow(101, "из очереди"));
pipeline.SeedRejected(RejectedRow(102, "из отсева")); pipeline.SeedRejected(RejectedRow(102, "из отсева"));
var kanj = new FakeKanjStore(); var kanj = new TestKanjStore();
kanj.SeedCard(Card(103, "из карточки")); 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); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -94,10 +93,10 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Candidates_FiltersByDialog() public async Task Candidates_FiltersByDialog()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); 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); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -108,10 +107,10 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Candidates_NoDialog_ReturnsAllSources() public async Task Candidates_NoDialog_ReturnsAllSources()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "a", "d_1")); pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
pipeline.SeedQueue(QueueRow(201, "b", "d_2")); 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); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
@@ -121,13 +120,13 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Candidates_ClampsLimitToMax() public async Task Candidates_ClampsLimitToMax()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
for (int i = 0; i < 5; i++) for (int i = 0; i < 5; i++)
{ {
pipeline.SeedQueue(QueueRow(100 + i, $"текст {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); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
@@ -137,10 +136,10 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_Skip_DoesNotLearn() public async Task Apply_Skip_DoesNotLearn()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст")); pipeline.SeedQueue(QueueRow(101, "текст"));
var ml = new FakeMlClient(); var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
@@ -154,10 +153,10 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_Spam_WithCard_TrashesAndLearns() public async Task Apply_Spam_WithCard_TrashesAndLearns()
{ {
var kanj = new FakeKanjStore(); var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "спамный текст")); kanj.SeedCard(Card(101, "спамный текст"));
var ml = new FakeMlClient(); var ml = new TestMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -172,10 +171,10 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "рекламный текст")); pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
var ml = new FakeMlClient(); var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_Board_MovesCardAndLearns() public async Task Apply_Board_MovesCardAndLearns()
{ {
var kanj = new FakeKanjStore(); var kanj = new TestKanjStore();
kanj.SeedBoard(new ContainerDto kanj.SeedBoard(new ContainerDto
{ {
Id = "b_py", Id = "b_py",
@@ -201,8 +200,8 @@ public sealed class MlReviewServiceTests
Kind = ContainerKinds.Board, Kind = ContainerKinds.Board,
}); });
kanj.SeedCard(Card(101, "python разработчик")); kanj.SeedCard(Card(101, "python разработчик"));
var ml = new FakeMlClient(); var ml = new TestMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
@@ -216,9 +215,9 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_UnknownBoard_ReturnsError() public async Task Apply_UnknownBoard_ReturnsError()
{ {
var kanj = new FakeKanjStore(); var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "текст")); 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); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
@@ -230,9 +229,9 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_UnknownAction_ReturnsError() public async Task Apply_UnknownAction_ReturnsError()
{ {
var pipeline = new FakePipelineStore(); var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст")); 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); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
@@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests
[Fact] [Fact]
public async Task Apply_MessageNotFound_ReturnsNull() 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); 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.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -15,7 +15,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards() public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
{ {
(StorageTickService service, FakeKanjStore store, _) = Create(); (StorageTickService service, TestKanjStore store, _) = Create();
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16)))); store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16))));
store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат
@@ -47,7 +47,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace() 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"); settings.Preload(SettingsKeys.AutoArchive, "false");
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30)))); store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
@@ -62,7 +62,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth() 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"); settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32)))); store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
@@ -78,7 +78,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard() 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"); settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2)))); store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
@@ -91,7 +91,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards() public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
{ {
(StorageTickService service, FakeKanjStore store, _) = Create(); (StorageTickService service, TestKanjStore store, _) = Create();
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20)))); store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
@@ -109,7 +109,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays() public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
{ {
(StorageTickService service, FakeKanjStore store, _) = Create(); (StorageTickService service, TestKanjStore store, _) = Create();
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн. store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
@@ -129,7 +129,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary() 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"); settings.Preload(SettingsKeys.ArchiveClearDays, "30");
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
@@ -148,7 +148,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays() 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_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6)))); store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
@@ -165,7 +165,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary() 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"); settings.Preload(SettingsKeys.TrashClearDays, "3");
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн. store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1)))); 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new StorageTickService(store, settings), store, settings); return (new StorageTickService(store.Store, settings.Store), store, settings);
} }
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка). // Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
@@ -215,13 +215,13 @@ public sealed class StorageTickServiceTests
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); 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; store.CardDtos.Single(card => card.Id == cardId).Col;
// Флаг is_new карточки после тика. // Флаг 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; 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; store.CardDtos.Single(card => card.Id == cardId).MatchHits;
} }
@@ -1,7 +1,6 @@
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline; namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
PipelineIngestResultDto result = await service.EnqueueAsync( PipelineIngestResultDto result = await service.EnqueueAsync(
Message( Message(
@@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow() public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
await service.EnqueueAsync( await service.EnqueueAsync(
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)), Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
@@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None); await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
@@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop() public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None); PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None); PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
@@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate() public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7"); QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7"); QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
@@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted() public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None); await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None); PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
@@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate() public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
PipelineIngestResultDto first = await service.EnqueueAsync( PipelineIngestResultDto first = await service.EnqueueAsync(
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None); Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
@@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests
[Fact] [Fact]
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints() public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store.Store);
string longText = new string('а', 7_000); string longText = new string('а', 7_000);
await service.EnqueueAsync(Message(longText), CancellationToken.None); 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.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline; namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task RejectAsync_WhitespaceText_Noop() 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); await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
@@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId() public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord await service.RejectAsync(new RejectRecord
{ {
@@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow() 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: "length", reason: "короткое"), CancellationToken.None);
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None); await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
@@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task QueueCountsAsync_CountsNewAndFiltered() 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_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2)); store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3)); store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
@@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount() 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.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2")); store.SeedRejected(Rejected("r_2"));
@@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne() 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_1", PipelineQueueStatuses.New, queuedAtMs: 30));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10)); store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20)); store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
@@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit() 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_1", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000)); store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
@@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName() 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_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000)); store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
@@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal() 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_1", text: "middle a", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000)); store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
@@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull() 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); RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
@@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_AlreadyReturned_Returns400() 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: "было")); store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
@@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_DuplicateSource_Returns400() 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")); store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_EmptyText_Returns400() 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: " ")); store.SeedRejected(Rejected("r_1", text: " "));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow() public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
{ {
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected( store.SeedRejected(Rejected(
"r_1", "r_1",
text: " Продвижение в каналах ", text: " Продвижение в каналах ",
@@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow() public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
{ {
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected( store.SeedRejected(Rejected(
"r_1", "r_1",
text: "Короткое сообщение", text: "Короткое сообщение",
@@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord() 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")); store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None); await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
@@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue() public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{ {
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected( store.SeedRejected(Rejected(
"r_old", "r_old",
text: "Старая запись без ссылки на исходное", text: "Старая запись без ссылки на исходное",
@@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task DeleteAsync_RemovesSingleRecord() public async Task DeleteAsync_RemovesSingleRecord()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2")); store.SeedRejected(Rejected("r_2"));
@@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ClearAsync_ReturnsCountAndEmptiesRejects() public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2")); store.SeedRejected(Rejected("r_2"));
store.SeedRejected(Rejected("r_3")); store.SeedRejected(Rejected("r_3"));
@@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention() public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, TestPipelineStore store, _) = Create();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
@@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests
// ─── Помощники ────────────────────────────────────────────────────────── // ─── Помощники ──────────────────────────────────────────────────────────
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария. // Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create() private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
{ {
FakePipelineStore store = new(); TestPipelineStore store = new();
FakeMlClient ml = new(); TestMlClient ml = new();
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store)); PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store));
return (service, store, ml); 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.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields() public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
var service = new AuditService(store); var service = new AuditService(store.Store);
var actorId = Guid.NewGuid(); var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var before = DateTimeOffset.UtcNow; var before = DateTimeOffset.UtcNow;
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst() 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.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.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow); 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); var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
Assert.Equal(3, all.Count); Assert.Equal(3, all.Count);
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task QueryAsync_AppliesAtRangeAndTenantFilters() public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var outsideTenantId = Guid.NewGuid(); var outsideTenantId = Guid.NewGuid();
var from = DateTimeOffset.UtcNow.AddMinutes(-10); 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.AddMinutes(-1)); // вне окна
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to); 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( var byRange = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None); new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit() public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < 7; i++) for (int i = 0; i < 7; i++)
{ {
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow); 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); var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None)); Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров). // Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
private static async Task AppendWithAtAsync( private static async Task AppendWithAtAsync(
FakeAuditLogStore store, TestAuditLogStore store,
string eventType, string eventType,
string actorType, string actorType,
Guid? tenantId, Guid? tenantId,
DateTimeOffset at) DateTimeOffset at)
{ {
await store.AppendAsync(new AuditRecordDto( await store.Store.AppendAsync(new AuditRecordDto(
eventType, eventType,
actorType, actorType,
ActorId: null, ActorId: null,
@@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary> /// <summary>
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере». /// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере».
/// </summary> /// </summary>
public sealed class AuthServiceTests public sealed class AuthServiceTests
{ {
@@ -17,17 +17,17 @@ public sealed class AuthServiceTests
private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid UserId = Guid.NewGuid();
private static readonly Guid UserTenantId = Guid.NewGuid(); private static readonly Guid UserTenantId = Guid.NewGuid();
private readonly FakeAuthStore _store; private readonly TestAuthStore _store;
private readonly TestTenantStore _tenantStore; private readonly TestTenantStore _tenantStore;
private readonly AuthService _service; private readonly AuthService _service;
public AuthServiceTests() public AuthServiceTests()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
_store = new FakeAuthStore(); _store = new TestAuthStore();
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); _tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); _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] [Fact]
@@ -133,7 +133,7 @@ public sealed class AuthServiceTests
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None); var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.Null(user); Assert.Null(user);
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища. // Очистка «протухших» сессий вызвана и удалила сессию из хранилища.
Assert.Contains("delete-expired", _store.Calls); Assert.Contains("delete-expired", _store.Calls);
Assert.Empty(_store.Sessions); Assert.Empty(_store.Sessions);
} }
@@ -204,10 +204,10 @@ public sealed class AuthServiceTests
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession() public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); 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); var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
@@ -225,10 +225,10 @@ public sealed class AuthServiceTests
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает // Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты). // приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); 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); var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
@@ -266,12 +266,12 @@ public sealed class AuthServiceTests
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser() public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var multiUserStore = new FakeAuthStore(); var multiUserStore = new TestAuthStore();
var firstUserId = Guid.NewGuid(); var firstUserId = Guid.NewGuid();
var secondUserId = Guid.NewGuid(); var secondUserId = Guid.NewGuid();
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1"))); 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"))); 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-адаптере). // login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None); 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 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"))); 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); var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(cross.Ok); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
@@ -70,7 +70,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); 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); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -87,7 +87,7 @@ public sealed class JoinFlowTests
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); 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 service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -108,7 +108,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -126,7 +126,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); 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 service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); 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 inviteStore = new TestInviteStore();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
@@ -159,7 +159,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); 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); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); 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); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -203,7 +203,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
@@ -221,7 +221,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); 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")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -248,7 +248,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -288,7 +288,7 @@ public sealed class JoinFlowTests
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится. // Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -311,7 +311,7 @@ public sealed class JoinFlowTests
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано. // Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -353,17 +353,17 @@ public sealed class JoinFlowTests
// ─── Хелперы ───────────────────────────────────────────────────────── // ─── Хелперы ─────────────────────────────────────────────────────────
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие). // Собирает JoinService на общих подставках хранилищ (все состояния — свежие).
private static JoinService NewService( private static JoinService NewService(
TestInviteStore inviteStore, TestInviteStore inviteStore,
TestTenantStore tenantStore, TestTenantStore tenantStore,
ITenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, TestAuthStore authStore,
IPasswordHasher passwordHasher) => IPasswordHasher passwordHasher) =>
new( new(
new InvitesService(inviteStore.Store), new InvitesService(inviteStore.Store),
new TenantService(tenantStore.Repository, provisioner), new TenantService(tenantStore.Repository, provisioner),
authStore, authStore.Store,
passwordHasher, passwordHasher,
tenantStore.Repository); tenantStore.Repository);
@@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests
private static readonly Guid OperatorId = Guid.NewGuid(); private static readonly Guid OperatorId = Guid.NewGuid();
private readonly FakeOperatorAuthStore _store; private readonly TestOperatorAuthStore _store;
private readonly OperatorAuthService _service; private readonly OperatorAuthService _service;
public OperatorAuthServiceTests() public OperatorAuthServiceTests()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore(); _store = new TestOperatorAuthStore();
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); _store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
_service = new OperatorAuthService(_store, passwordHasher); _service = new OperatorAuthService(_store.Store, passwordHasher);
} }
[Fact] [Fact]
@@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants;
/// </summary> /// </summary>
public sealed class OperatorBootstrapServiceTests public sealed class OperatorBootstrapServiceTests
{ {
private readonly FakeOperatorAuthStore _store; private readonly TestOperatorAuthStore _store;
private readonly IPasswordHasher _passwordHasher; private readonly IPasswordHasher _passwordHasher;
private readonly OperatorBootstrapService _service; private readonly OperatorBootstrapService _service;
public OperatorBootstrapServiceTests() public OperatorBootstrapServiceTests()
{ {
_passwordHasher = TestHashers.New(); _passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore(); _store = new TestOperatorAuthStore();
_service = new OperatorBootstrapService(_store, _passwordHasher); _service = new OperatorBootstrapService(_store.Store, _passwordHasher);
} }
[Fact] [Fact]
@@ -1,5 +1,6 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_NoRecords_ReturnsEmpty() 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); SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold() public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{ {
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i); SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh() public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2; int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers() public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
{ {
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i); SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers() public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
{ {
await store.AppendAsync( await store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginOk, AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers() public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++) for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
{ {
await store.AppendAsync( await store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold() public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{ {
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i); SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh() public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2; int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding() public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
{ {
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i); SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding() public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
{ {
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i); SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored() public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{ {
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i); SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < 50; i++) for (int i = 0; i < 50; i++)
{ {
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток 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( private static void SeedFailed(
FakeAuditLogStore store, TestAuditLogStore store,
string ip, string ip,
string login, string login,
int minutesAgo) int minutesAgo)
{ {
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «успешный вход» с заданным временем. // Пишет запись «успешный вход» с заданным временем.
private static void SeedSuccess( private static void SeedSuccess(
FakeAuditLogStore store, TestAuditLogStore store,
string ip, string ip,
string login, string login,
int minutesAgo) int minutesAgo)
{ {
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginOk, AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
CancellationToken.None); 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 tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner(); 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); TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
@@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword() public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var hasher = TestHashers.New(); var hasher = TestHashers.New();
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher); var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
@@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant() public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
var service = NewService(tenantStore, authStore); var service = NewService(tenantStore, authStore);
@@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail() public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
{ {
var tenantStore = new TestTenantStore(); 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); TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
@@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired() public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
{ {
var tenantStore = new TestTenantStore(); 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); TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
@@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); 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, "a@example.com"));
authStore.AddUser(NewUser(FirstTenantId, "b@example.com")); authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
@@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers() public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); 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(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
var service = NewService(tenantStore, authStore); var service = NewService(tenantStore, authStore);
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
[Fact] [Fact]
public async Task GetAsync_ForUnknownTenant_ReturnsNull() 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)); Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
} }
@@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged() public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); 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); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange() public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); 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 без изменения (аудит не дублируется, идемпотентность). // Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
[Fact] [Fact]
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound() 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); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
@@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests
Assert.Null(result.Tenant); Assert.Null(result.Tenant);
} }
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку). // Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку).
private static TenantAdminService NewService( private static TenantAdminService NewService(
TestTenantStore tenantStore, TestTenantStore tenantStore,
FakeAuthStore authStore, TestAuthStore authStore,
TestTenantProvisioner? provisioner = null, TestTenantProvisioner? provisioner = null,
IPasswordHasher? hasher = null) IPasswordHasher? hasher = null)
{ {
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner); 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.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary> /// <summary>
/// In-memory реализация <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов /// Подставка <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов:
/// сервисы получают NSubstitute-подставку (<see cref="Store"/>), тесты управляют сценарием через свойства
/// </summary> /// </summary>
public sealed class FakeTenantLimitStore : ITenantLimitStore public sealed class TestTenantLimitStore
{ {
private sealed class Row private sealed class Row
{ {
@@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
private readonly Dictionary<Guid, Row> _rows = new(); private readonly Dictionary<Guid, Row> _rows = new();
private readonly TokenBudgetService _budgetService = new(); private readonly TokenBudgetService _budgetService = new();
/// <summary>
/// Подставка порта лимитов (создаётся в конструкторе).
/// </summary>
public ITenantLimitStore Store { get; }
private readonly Func<DateTimeOffset> _utcNow; private readonly Func<DateTimeOffset> _utcNow;
/// <summary> /// <summary>
/// Создаёт фейк с системными часами и статусом тенанта active. /// Создаёт подставку с системными часами и статусом тенанта active.
/// </summary> /// </summary>
public FakeTenantLimitStore() public TestTenantLimitStore()
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active) : this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
{ {
} }
/// <summary> /// <summary>
/// Создаёт фейк с заданными часами и статусом тенанта /// Создаёт подставку с заданными часами и статусом тенанта
/// </summary> /// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param> /// <param name="utcNow">Источник текущего времени (UTC).</param>
/// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</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); ArgumentNullException.ThrowIfNull(utcNow);
_utcNow = utcNow; _utcNow = utcNow;
TenantStatus = tenantStatus; 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> /// <summary>
@@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
public string TenantStatus { get; set; } public string TenantStatus { get; set; }
/// <summary> /// <summary>
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков) /// Тенанты, для которых <see cref="GetStateAsync"/> подставки бросает исключение (устойчивость сборщиков)
/// </summary> /// </summary>
public HashSet<Guid> FailStateReads { get; } = new(); public HashSet<Guid> FailStateReads { get; } = new();
/// <summary> /// <summary>
/// Кладёт готовую строку лимита /// Кладёт готовую строку лимита (в состояние подставки)
/// </summary> /// </summary>
/// <param name="tenantId">Тенант.</param> /// <param name="tenantId">Тенант.</param>
/// <param name="budgetTokens">Бюджет периода.</param> /// <param name="budgetTokens">Бюджет периода.</param>
@@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
/// <returns>True — строка существует.</returns> /// <returns>True — строка существует.</returns>
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId); public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
/// <inheritdoc /> private Task<TenantLimitDto> GetOrCreateAsync(
public Task<TenantLimitDto> GetOrCreateAsync(
Guid tenantId, Guid tenantId,
CancellationToken ct, CancellationToken ct,
TokenLimitDefaults? defaults = null) TokenLimitDefaults? defaults)
{ {
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default); Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
return Task.FromResult(ToLimitDto(row, tenantId)); return Task.FromResult(ToLimitDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
{ {
if (FailStateReads.Contains(tenantId)) if (FailStateReads.Contains(tenantId))
{ {
@@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> AddUsageAsync(
public Task<BudgetStateDto> AddUsageAsync(
Guid tenantId, Guid tenantId,
long tokens, long tokens,
CancellationToken ct) CancellationToken ct)
@@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<BudgetStateDto> UpdateBudgetAsync(
public Task<BudgetStateDto> UpdateBudgetAsync(
Guid tenantId, Guid tenantId,
long budgetTokens, long budgetTokens,
string period, string period,
@@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId)); return Task.FromResult(ToStateDto(row, tenantId));
} }
/// <inheritdoc /> private Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{ {
Row row = Ensure(tenantId, TokenBudgetDefaults.Default); Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row); ResetIfPeriodExpired(row);
@@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true); return Task.FromResult(true);
} }
/// <inheritdoc /> private Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{ {
Row row = Ensure(tenantId, TokenBudgetDefaults.Default); Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row); ResetIfPeriodExpired(row);
@@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true); return Task.FromResult(true);
} }
/// <inheritdoc /> private Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{ {
int reset = 0; int reset = 0;
foreach (Row row in _rows.Values) foreach (Row row in _rows.Values)
@@ -3,6 +3,7 @@ using Deal.Api.Dtos;
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models; 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.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using NSubstitute;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -28,9 +29,9 @@ public sealed class AdminTickOrchestratorTests
// Контекст теста: оркестратор на общих фейках + канал подписки. // Контекст теста: оркестратор на общих фейках + канал подписки.
private sealed record Context( private sealed record Context(
AdminTickOrchestrator Orchestrator, AdminTickOrchestrator Orchestrator,
FakePipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, TestKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestAiClassifier AiClassifier, TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate, PipelinePumpGate PumpGate,
SseSubscription Subscription); SseSubscription Subscription);
@@ -158,7 +159,7 @@ public sealed class AdminTickOrchestratorTests
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
} }
[Fact] [Fact]
@@ -194,33 +195,39 @@ public sealed class AdminTickOrchestratorTests
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings, // Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService); // обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
// ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»). // ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»).
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump). // withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки). // withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
// Возвращает: Оркестратор, фейки и подписку канала тенанта. // Возвращает: Оркестратор, фейки и подписку канала тенанта.
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false) 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(); TestPipelineStore store = new();
var kanjStore = new FakeKanjStore(); if (withThrowingQueueRead)
var mlClient = new FakeMlClient(); {
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 aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var ingest = new PipelineIngestService(store); var ingest = new PipelineIngestService(store.Store);
var processing = new PipelineProcessingService(store, mlClient, ingest); var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store, composer); var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService( 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 broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings); var tickService = new StorageTickService(kanjStore.Store, settings.Store);
var toastPublisher = new StorageToastPublisher(broker); var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate(); var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore; TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore;
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage); var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator( var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate, tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance); NullLogger<AdminTickOrchestrator>.Instance);
@@ -229,16 +236,7 @@ public sealed class AdminTickOrchestratorTests
broker.Subscribe(TenantA)); broker.Subscribe(TenantA));
} }
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий // Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 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; сценарий перекрывает текст/время). // Строка очереди (status new; сценарий перекрывает текст/время).
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue( private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
id, id,
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Hosting; using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
@@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA)); Assert.Empty(ReadToasts(subscriptionA));
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None));
} }
[Fact] [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); limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact] [Fact]
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
{ {
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
BudgetAlertScheduler scheduler = CreateScheduler(provider); 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); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
@@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
// Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. // Расход до исчерпания (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); await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
@@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests
// tenants: Фейк реестра тенантов (обход прохода). // tenants: Фейк реестра тенантов (обход прохода).
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton(tenants); services.AddSingleton(tenants);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddSingleton<SseBroker>(); services.AddSingleton<SseBroker>();
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; 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.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -21,9 +21,9 @@ public sealed class CardReclassifierTests
// Контекст теста: сервис поверх in-memory фейков. // Контекст теста: сервис поверх in-memory фейков.
private sealed record Context( private sealed record Context(
CardReclassifier Reclassifier, CardReclassifier Reclassifier,
FakeKanjStore Store, TestKanjStore Store,
FakeSettingsStore Settings, TestSettingsStore Settings,
FakeMlClient MlClient, TestMlClient MlClient,
TestAiClassifier AiClassifier, TestAiClassifier AiClassifier,
ReclassifyGate Gate); ReclassifyGate Gate);
@@ -31,16 +31,16 @@ public sealed class CardReclassifierTests
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
private static Context CreateContext() private static Context CreateContext()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var store = new FakeKanjStore(); var store = new TestKanjStore();
var mlClient = new FakeMlClient(); var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier(); var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var composer = new CardComposer(store, settings); var composer = new CardComposer(store.Store, settings.Store);
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage); var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate(); var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier( 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); 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.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() 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)); store.SeedCard(Card("c_1", updatedAtMs: 1));
byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
using MemoryStream stream = new(content); using MemoryStream stream = new(content);
@@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
{ {
(CardsService service, FakeKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")); store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync( CardFileDto? entry = await service.AddFileAsync(
@@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() 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")); store.SeedCard(Card("c_1"));
CardFileDto? first = await service.AddFileAsync( CardFileDto? first = await service.AddFileAsync(
@@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() 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( CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
@@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
{ {
(CardsService service, FakeKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")); store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync( CardFileDto? entry = await service.AddFileAsync(
@@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() 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")); store.SeedCard(Card("c_1"));
string rawName = "..\\файл\"отчёта v2.pdf"; string rawName = "..\\файл\"отчёта v2.pdf";
@@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Add_StreamPositionNotZero_StoresWholeContent() 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")); store.SeedCard(Card("c_1"));
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
@@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task GetEntry_ExistingFile_ReturnsEntryMeta() 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"); 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 } }); store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull() public async Task GetEntry_FileNotInMetadata_ReturnsNull()
{ {
(CardsService service, FakeKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1") store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
@@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() 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 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"); 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 } }); store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
@@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() 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"); 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 } }); store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests
[Fact] [Fact]
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() 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") store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); 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(); 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 пуст). // Карточка с полями по умолчанию (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.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() 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_1", updatedAtMs: 100));
store.SeedCard(Card("c_2", updatedAtMs: 300)); store.SeedCard(Card("c_2", updatedAtMs: 300));
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
@@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task List_ByStage_ReturnsOnlyStageCards() 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_1"));
store.SeedCard(Card("c_2", stage: "work")); store.SeedCard(Card("c_2", stage: "work"));
@@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardDto card = await service.CreateLocalCardAsync( CardDto card = await service.CreateLocalCardAsync(
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
@@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() 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); CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
@@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task TakeCard_MovesCardToPlannedKeepingFields() public async Task TakeCard_MovesCardToPlannedKeepingFields()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card( store.SeedCard(Card(
"c_1", "c_1",
stage: "inbox", stage: "inbox",
@@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
@@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card( store.SeedCard(Card(
"c_1", "c_1",
title: "Старый заголовок", title: "Старый заголовок",
@@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Patch_BudgetNull_ClearsBudget() public async Task Patch_BudgetNull_ClearsBudget()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
with { Budget = new CardBudgetDto(1000, 2000, "USD") }); with { Budget = new CardBudgetDto(1000, 2000, "USD") });
@@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Patch_StackNull_ClearsStack() public async Task Patch_StackNull_ClearsStack()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
with { Stack = new[] { "Python" } }); with { Stack = new[] { "Python" } });
@@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Patch_UnknownKeys_AreIgnored() public async Task Patch_UnknownKeys_AreIgnored()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Старый")); store.SeedCard(Card("c_1", title: "Старый"));
CardDto? card = await service.PatchCardAsync( CardDto? card = await service.PatchCardAsync(
@@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Patch_NullTextKey_IsIgnored() 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)); store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1));
CardDto? card = await service.PatchCardAsync( CardDto? card = await service.PatchCardAsync(
@@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
long seededAt = 5_000; long seededAt = 5_000;
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
with with
@@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "planned")); store.SeedCard(Card("c_1", stage: "planned"));
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
@@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() 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_1", stage: "rejected"));
store.SeedCard(Card("c_2", stage: "rejected")); store.SeedCard(Card("c_2", stage: "rejected"));
store.SeedCard(Card("c_3", stage: "finished")); store.SeedCard(Card("c_3", stage: "finished"));
@@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
@@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddComment_TwoComments_AppendsPreservingOrder() public async Task AddComment_TwoComments_AppendsPreservingOrder()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка") store.SeedCard(Card("c_1", title: "Карточка")
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
@@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")); store.SeedCard(Card("c_1", title: "Карточка"));
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
@@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddComment_CardMissing_ReturnsNullComments() 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); AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None);
@@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
@@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")); store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
@@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")); store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
@@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() 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); CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
@@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with with
{ {
@@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка") store.SeedCard(Card("c_1", title: "Карточка")
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
@@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests
[Fact] [Fact]
public async Task RemoveLink_CardMissing_ReturnsNullCard() 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); 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var ml = new FakeMlClient(); var ml = new TestMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml); return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
} }
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -1,7 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -29,7 +28,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task Create_ExistingContainers_OrderIsMaxPlusOne() 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_2", 2));
store.SeedBoard(Container("b_5", 5)); store.SeedBoard(Container("b_5", 5));
@@ -43,7 +42,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task Create_OrderEight_CyclesPaletteToFirst() public async Task Create_OrderEight_CyclesPaletteToFirst()
{ {
(ContainersService service, FakeKanjStore store, _) = Create(); (ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_7", 7)); store.SeedBoard(Container("b_7", 7));
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None); ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
@@ -157,7 +156,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task List_FillsCountsFromCards() 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); 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_1", Col = container.Id, IsNew = true });
store.SeedCard(new CardDto { Id = "c_2", 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] [Fact]
public async Task Reorder_AssignsOrdersInGivenOrder() 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_1", 0));
store.SeedBoard(Container("b_2", 1)); store.SeedBoard(Container("b_2", 1));
store.SeedBoard(Container("b_3", 2)); store.SeedBoard(Container("b_3", 2));
await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None); 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(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order); Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order); Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
} }
// ─── Удаление контейнера ────────────────────────────────────────────── // ─── Удаление контейнера ──────────────────────────────────────────────
@@ -300,7 +299,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task Delete_MovesCardsToInbox_ReturnsMovedCount() 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); ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
store.AddCard("c_1", container.Id); store.AddCard("c_1", container.Id);
store.AddCard("c_2", 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); int moved = await service.DeleteAsync(container.Id, CancellationToken.None);
Assert.Equal(2, moved); 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_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_2" && card.Col == "inbox" && card.IsNew);
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
@@ -340,7 +339,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task GetColState_ReturnsStoredColumnsAsIs() public async Task GetColState_ReturnsStoredColumnsAsIs()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}"""); settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None); IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -353,7 +352,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt() public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None); "inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
@@ -368,7 +367,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent() public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true}}"""); settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
@@ -380,7 +379,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_NullFields_KeepCurrentState() public async Task PatchColState_NullFields_KeepCurrentState()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}"""); settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
@@ -392,7 +391,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry() public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true}}"""); settings.Preload("colState", """{"archive":{"collapsed":true}}""");
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None); await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
@@ -408,7 +407,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task GetColState_CorruptJson_ReturnsEmpty() public async Task GetColState_CorruptJson_ReturnsEmpty()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", "{oops"); settings.Preload("colState", "{oops");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None); 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new ContainersService(store, settings), store, settings); return (new ContainersService(store.Store, settings.Store), store, settings);
} }
// Патч только с нужными полями: остальные параметры null = «не менять». // Патч только с нужными полями: остальные параметры null = «не менять».
@@ -3,8 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -19,7 +19,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); 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")); store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
@@ -34,7 +34,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency() 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.RatesCache, CacheJson(MockRates.Values));
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey) settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -49,7 +49,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -62,7 +62,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_UsdtBudget_TreatedAsUsdRate() 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 }; var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates)); settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
@@ -78,7 +78,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
@@ -93,7 +93,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched() 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.ConversionOn, "false");
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -109,7 +109,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_ExcludesArchiveAndTrashCards() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); 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")); store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
@@ -128,7 +128,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion() 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 })); 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 store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
{ {
@@ -147,7 +147,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched() 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")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
int updated = await service.RecomputeAsync(CancellationToken.None); int updated = await service.RecomputeAsync(CancellationToken.None);
@@ -159,7 +159,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_CorruptedRatesCache_ReturnsZero() 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"); settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -174,7 +174,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task OnRatesChangedAsync_PerformsFullRecompute() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -189,7 +189,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_SecondRun_RecomputesSameValues() 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)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); 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 store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new ConversionRecomputer(settings, store), store, settings); return (new ConversionRecomputer(settings.Store, store.Store), store, settings);
} }
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера). // Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
@@ -237,7 +237,7 @@ public sealed class ConversionRecomputerTests
// Конверсия (Converted) карточки в фейке после пересчёта. // Конверсия (Converted) карточки в фейке после пересчёта.
// store: Фейк-хранилище. // store: Фейк-хранилище.
// cardId: Id карточки. // 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; store.CardDtos.Single(card => card.Id == cardId).Converted;
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту). // Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
@@ -247,7 +247,7 @@ public sealed class ConversionRecomputerTests
// convTo: Ожидаемая верхняя граница. // convTo: Ожидаемая верхняя граница.
// convCur: Ожидаемая валюта конверсии. // convCur: Ожидаемая валюта конверсии.
private static void AssertConverted( private static void AssertConverted(
FakeKanjStore store, TestKanjStore store,
string cardId, string cardId,
double convFrom, double convFrom,
double convTo, double convTo,
@@ -15,7 +15,6 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc; using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -52,12 +51,12 @@ public sealed class GrpcAiClassifierTests
Reason = "реклама", Reason = "реклама",
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, 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.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); 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) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.FilterUnavailable = true; 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); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
@@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
string text = new string('а', 5000); 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); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.FilterAsync(text, CancellationToken.None); await classifier.FilterAsync(text, CancellationToken.None);
@@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests
""", """,
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, 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.AiPrompt, Json("Разбор: {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
@@ -152,7 +151,7 @@ public sealed class GrpcAiClassifierTests
Order = 0, Order = 0,
}); });
FakeTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
@@ -191,9 +190,9 @@ public sealed class GrpcAiClassifierTests
Ok = false, Ok = false,
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, 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); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
@@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.ClassifyUnavailable = true; 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); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
@@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; 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.AiProvider, Json("anthropic"));
settings.Preload( settings.Preload(
SettingsKeys.AiConfigs, SettingsKeys.AiConfigs,
@@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests
{ {
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000); 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); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None); await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -284,28 +283,29 @@ public sealed class GrpcAiClassifierTests
// Возвращает: Экземпляр GrpcAiClassifier. // Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier( private static GrpcAiClassifier CreateClassifier(
int port, int port,
FakeSettingsStore settings, TestSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
FakeKanjStore kanjStore, TestKanjStore kanjStore,
FakeTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
{ {
limits ??= new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier( return new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, cipher), new AiProviderConfigBuilder(settings.Store, cipher),
new AiClassifyContextBuilder(settings, kanjStore), new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
} }
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
// port: Порт хоста (не используется контекстом — единый вид хелперов). // port: Порт хоста (не используется контекстом — единый вид хелперов).
// Возвращает: Кортеж фейков (настройки, шифр, канбан). // Возвращает: Кортеж фейков (настройки, шифр, канбан).
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port) private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port)
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore()); => (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore());
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore). // Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
// value: Значение (строка/список). // value: Значение (строка/список).
@@ -313,7 +313,7 @@ public sealed class GrpcAiClassifierTests
private static string Json(object value) => JsonSerializer.Serialize(value); private static string Json(object value) => JsonSerializer.Serialize(value);
private static async Task AssertUsageAsync( private static async Task AssertUsageAsync(
FakeSettingsStore settings, TestSettingsStore settings,
uint prompt, uint prompt,
uint completion, uint completion,
uint total) uint total)
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
// Создаёт правила поверх in-memory KV-хранилища. // Создаёт правила поверх in-memory KV-хранилища.
// store: Хранилище настроек тенанта. // store: Хранилище настроек тенанта.
// Возвращает: Экземпляр IncomingRules. // Возвращает: Экземпляр IncomingRules.
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store); private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore). // Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (строка/число/булево/список). // value: Значение (строка/число/булево/список).
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength() public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
{ {
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync("Короткий текст", CancellationToken.None); .CheckAsync("Короткий текст", CancellationToken.None);
Assert.False(result.Pass); Assert.False(result.Pass);
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_EmptyText_IsLengthFail() public async Task CheckAsync_EmptyText_IsLengthFail()
{ {
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen». // (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync(" ", CancellationToken.None); .CheckAsync(" ", CancellationToken.None);
Assert.False(result.Pass); Assert.False(result.Pass);
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride() public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MinLen, Json(10)); store.Preload(SettingsKeys.MinLen, Json(10));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_LongCleanTextWithDefaults_Passes() public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
{ {
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync( IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу", "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
CancellationToken.None); CancellationToken.None);
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason() public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" })); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase() public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" })); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker() public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeText_BlockResumesOff_Passes() 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.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.BlockResumes, Json(false)); store.Preload(SettingsKeys.BlockResumes, Json(false));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut() public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass() public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType() 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.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance")); store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType() 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.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("vacancy")); store.Preload(SettingsKeys.WantedType, Json("vacancy"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes() 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.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance")); store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes() public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
{ {
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается. // Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -37,10 +37,10 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore, inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
@@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests
Assert.False(response.Headers.Contains("Set-Cookie")); Assert.False(response.Headers.Contains("Set-Cookie"));
}); });
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. // Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated.
StoredUserDto user = Assert.Single(authStore.Users); StoredUserDto user = Assert.Single(authStore.Users);
Assert.Equal(Email, user.Login); Assert.Equal(Email, user.Login);
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash); Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
@@ -80,10 +80,10 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using (HttpResponseMessage first = await PostJsonAsync( using (HttpResponseMessage first = await PostJsonAsync(
@@ -108,10 +108,10 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync( 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) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync( 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) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound() public async Task Join_WithUnknownCode_Returns400NotFound()
{ {
await RunAsync( 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) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
await RunAsync( 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) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -201,11 +201,11 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); 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")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -228,10 +228,10 @@ public sealed class JoinEndpointHttpTests
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(), inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( 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( private static async Task RunAsync(
TestInviteStore inviteStore, TestInviteStore inviteStore,
TestTenantStore tenantStore, TestTenantStore tenantStore,
ITenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, TestAuthStore authStore,
FakeAuditLogStore auditStore, TestAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario) Func<string, HttpClient, Task> scenario)
{ {
int port = TestPort.Allocate(); int port = TestPort.Allocate();
@@ -264,8 +264,8 @@ public sealed class JoinEndpointHttpTests
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает // Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
// последняя регистрация (зеркало OperatorAuthHttpHost). // последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore); builder.Services.AddSingleton<IAuthStore>(authStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(auditStore); builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store); builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository); builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner); 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.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -17,18 +17,18 @@ public sealed class LocalColumnSuggesterTests
{ {
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля. // Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер). // Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext() private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
{ {
var store = new FakeKanjStore(); var store = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings)); var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store));
return (store, settings, suggester); return (store, settings, suggester);
} }
private static void SeedInbox( private static void SeedInbox(
string id, string id,
string text, string text,
FakeKanjStore store, TestKanjStore store,
long receivedAtMs = 0) => long receivedAtMs = 0) =>
store.SeedCard(new CardDto store.SeedCard(new CardDto
{ {
@@ -57,7 +57,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason() public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
for (int i = 1; i < SuggestHeuristics.MinInbox; i++) for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
{ {
SeedInbox($"l_{i}", "нужен python", store); SeedInbox($"l_{i}", "нужен python", store);
@@ -72,7 +72,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason() public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
{ {
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
settings.Preload(SettingsKeys.LastSuggestAt, settings.Preload(SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
SeedInbox("l_1", "нужен python", store); SeedInbox("l_1", "нужен python", store);
@@ -87,7 +87,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason() public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"]; string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++) for (int i = 0; i < uniqueWords.Length; i++)
{ {
@@ -103,7 +103,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason() public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
store.SeedBoard(new ContainerDto store.SeedBoard(new ContainerDto
{ {
Id = "b_python", Id = "b_python",
@@ -128,7 +128,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards() 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_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store); SeedInbox("l_p3", "нужен python", store);
@@ -169,7 +169,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown() public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store); SeedInbox("l_p3", "нужен python", store);
@@ -191,7 +191,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers() public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store); SeedInbox("l_p3", "нужен python", store);
@@ -209,7 +209,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestKeywords_TooFewCards_ReturnsReason() public async Task SuggestKeywords_TooFewCards_ReturnsReason()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store); SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен python", store); SeedInbox("l_2", "нужен python", store);
@@ -223,7 +223,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted() public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store); SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен vue", store); SeedInbox("l_2", "нужен vue", store);
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } }); store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
@@ -239,7 +239,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason() public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
{ {
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"]; string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++) for (int i = 0; i < uniqueWords.Length; i++)
{ {
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -27,14 +28,17 @@ public sealed class LocalMlClientTests
// store: Хранилище настроек тенанта. // store: Хранилище настроек тенанта.
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0). // learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
// Возвращает: Экземпляр LocalMlClient. // Возвращает: Экземпляр LocalMlClient.
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
=> new(store, learning ?? new FakeMlLearningStore()); {
learning ??= new TestMlLearningStore();
return new(store.Store, learning.Store);
}
[Fact] [Fact]
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
{ {
LocalMlClient client = CreateClient(new FakeSettingsStore()); LocalMlClient client = CreateClient(new TestSettingsStore());
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -63,7 +67,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredCounters_AreReflectedInStats() public async Task StatusAsync_StoredCounters_AreReflectedInStats()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3"); store.Preload(SettingsKeys.AiDecisions, "3");
LocalMlClient client = CreateClient(store); LocalMlClient client = CreateClient(store);
@@ -78,7 +82,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredMlEnabledFalse_Disables() public async Task StatusAsync_StoredMlEnabledFalse_Disables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false"); store.Preload(SettingsKeys.MlEnabled, "false");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -89,7 +93,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredMlEnabledTrue_Enables() public async Task StatusAsync_StoredMlEnabledTrue_Enables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "true"); store.Preload(SettingsKeys.MlEnabled, "true");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -102,7 +106,7 @@ public sealed class LocalMlClientTests
[InlineData("не json {{")] [InlineData("не json {{")]
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson) public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, valueJson); store.Preload(SettingsKeys.MlEnabled, valueJson);
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -113,7 +117,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_DamagedCounter_DegradesToZero() public async Task StatusAsync_DamagedCounter_DegradesToZero()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "не число"); store.Preload(SettingsKeys.MlDecisions, "не число");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -124,8 +128,8 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_ReportsLearningAndOutboxFromTables() public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
LocalMlClient client = CreateClient(store, learning); LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None); await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None); await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
@@ -142,7 +146,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape() public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
{ {
LocalMlClient client = CreateClient(new FakeSettingsStore()); LocalMlClient client = CreateClient(new TestSettingsStore());
MlPredictResultDto result = await client.PredictAsync( MlPredictResultDto result = await client.PredictAsync(
"Python backend на fastapi, бот в телеграм", CancellationToken.None); "Python backend на fastapi, бот в телеграм", CancellationToken.None);
@@ -161,8 +165,8 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None); await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
@@ -181,8 +185,8 @@ public sealed class LocalMlClientTests
[InlineData("текст", " ")] // label из пробелов — пуста после trim [InlineData("текст", " ")] // label из пробелов — пуста после trim
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label) public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(text, label, 1.0, CancellationToken.None); await client.PushAsync(text, label, 1.0, CancellationToken.None);
@@ -192,8 +196,8 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength() public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
string longText = new string('а', MaxLearningTextLength + 1000); string longText = new string('а', MaxLearningTextLength + 1000);
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None); await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
@@ -206,8 +210,8 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair() public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару. // Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
string text = new string('x', MaxLearningTextLength - 1) + "🙂"; string text = new string('x', MaxLearningTextLength - 1) + "🙂";
@@ -222,8 +226,8 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PushAsync_NegativeDelta_IsStoredAsIs() public async Task PushAsync_NegativeDelta_IsStoredAsIs()
{ {
var learning = new FakeMlLearningStore(); var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None); await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
@@ -236,7 +240,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_ReturnsOk() 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.True(result.Ok);
Assert.Null(result.Error); Assert.Null(result.Error);
@@ -245,7 +249,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_DoesNotTouchSettingsRows() public async Task ResetAsync_DoesNotTouchSettingsRows()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
LocalMlClient client = CreateClient(store); LocalMlClient client = CreateClient(store);
@@ -259,9 +263,9 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters() public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); 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); LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None); await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None); await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
@@ -278,7 +282,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat() public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false"); store.Preload(SettingsKeys.MlEnabled, "false");
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3"); store.Preload(SettingsKeys.AiDecisions, "3");
@@ -316,7 +320,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlPredictResultDto_SerializesToPrototypeWireFormat() 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); string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -329,7 +333,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlResetResultDto_Success_SerializesWithoutErrorKey() 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); string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail() public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
{ {
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(), new TestOperatorAuthStore(),
NewUserStore(), NewUserStore(),
async (baseAddress, _, users, _, _, _, _) => async (baseAddress, _, users, _, _, _, _) =>
{ {
@@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked() public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
{ {
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(), new TestOperatorAuthStore(),
NewUserStore(), NewUserStore(),
async (baseAddress, _, users, _, _, _, _) => async (baseAddress, _, users, _, _, _, _) =>
{ {
@@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests
using var capture = new SecurityEventCapture(); using var capture = new SecurityEventCapture();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(), new TestOperatorAuthStore(),
NewUserStore(), NewUserStore(),
async (baseAddress, _, _, _, _, _, _) => async (baseAddress, _, _, _, _, _, _) =>
{ {
@@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests
new() { Enabled = true }; new() { Enabled = true };
// Фейк-хранилище пользователей с активным пользователем Login/Password. // Фейк-хранилище пользователей с активным пользователем Login/Password.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
Login, Login,
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
public async Task ParseAsync_ReadsMarkerOverridesFromSettings() public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
{ {
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер. // Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" })); store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
LocalFieldsParser parser = new(store); LocalFieldsParser parser = new(store.Store);
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default); LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default); LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
[Fact] [Fact]
public async Task ParseAsync_NoOverrides_UsesDefaults() public async Task ParseAsync_NoOverrides_UsesDefaults()
{ {
LocalFieldsParser parser = new(new FakeSettingsStore()); LocalFieldsParser parser = new(new TestSettingsStore().Store);
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default); LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters() 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.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst() public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{ {
await auditStore.AppendAsync( await auditStore.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Activity_FiltersByActorIdAndPaginates() public async Task Activity_FiltersByActorIdAndPaginates()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_FiltersByActorIdAndOffset() public async Task Audit_FiltersByActorIdAndOffset()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); 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(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
return store; return store;
} }
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
return store; return store;
} }
private static Task SeedAuditAsync( private static Task SeedAuditAsync(
FakeAuditLogStore store, TestAuditLogStore store,
string eventType, string eventType,
string actorType, string actorType,
Guid? tenantId, Guid? tenantId,
Guid? actorId = null, Guid? actorId = null,
int minutesAgo = 1) => int minutesAgo = 1) =>
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
eventType, eventType,
actorType, actorType,
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord() public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord() public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords() public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_ReturnsItemsNewestFirstWithTotal() public async Task Audit_ReturnsItemsNewestFirstWithTotal()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC). // Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
await auditStore.AppendAsync(new AuditRecordDto( await auditStore.Store.AppendAsync(new AuditRecordDto(
AuditEvents.TenantCreated, AuditEvents.TenantCreated,
AuditActorTypes.Operator, AuditActorTypes.Operator,
ActorId: Guid.NewGuid(), ActorId: Guid.NewGuid(),
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_FiltersByEventTypeAndActorType() public async Task Audit_FiltersByEventTypeAndActorType()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task TenantLogout_WritesTenantLogoutAuditRecord() public async Task TenantLogout_WritesTenantLogoutAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord() public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests
// ─── Хелперы ───────────────────────────────────────────────────────── // ─── Хелперы ─────────────────────────────────────────────────────────
// Фейк-хранилище оператора с активным operator/operator. // Фейк-хранилище оператора с активным operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests
} }
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
UserLogin, UserLogin,
@@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401() public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
{ {
FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false); TestOperatorAuthStore operatorStore = NewOperatorStore(active: false);
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
operatorStore, operatorStore,
@@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests
} }
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator. // Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore(bool active = true) private static TestOperatorAuthStore NewOperatorStore(bool active = true)
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests
} }
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
UserLogin, UserLogin,
@@ -33,17 +33,17 @@ internal static class OperatorAuthHttpHost
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3"; "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
/// <summary> /// <summary>
/// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей. /// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей.
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param> /// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, Task> scenario,
FakeAuditLogStore? auditStore = null) => TestAuditLogStore? auditStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
userStore, userStore,
@@ -56,16 +56,16 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param> /// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) => TestInviteStore? inviteStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
@@ -79,17 +79,17 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param> /// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param> /// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) => TestTenantStore? tenantStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -104,22 +104,22 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param> /// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param> /// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param> /// <param name="limitStore">Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param> /// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null, TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null, TestTenantLimitStore? limitStore = null,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null) => TestTokenUsageEventStore? tokenUsageStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -136,16 +136,16 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий с глобальными настройками /// Прогоняет сценарий с глобальными настройками
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param> /// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param> /// <param name="globalSettingsStore">Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
public static async Task RunWithGlobalSettingsAsync( public static async Task RunWithGlobalSettingsAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) => TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
@@ -159,21 +159,21 @@ internal static class OperatorAuthHttpHost
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки). // Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
private static async Task RunCoreAsync( private static async Task RunCoreAsync(
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
FakeAuditLogStore? auditStore, TestAuditLogStore? auditStore,
TestInviteStore? inviteStore, TestInviteStore? inviteStore,
TestTenantStore? tenantStore, TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore, TestTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null, TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) TestGlobalSettingsStore? globalSettingsStore = null)
{ {
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore(); TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore(); TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore();
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore(); TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore(); TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
@@ -186,12 +186,12 @@ internal static class OperatorAuthHttpHost
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<ITenantContext, TenantContext>(); builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore); builder.Services.AddSingleton<IAuthStore>(userStore.Store);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore); builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore); builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store); builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository); 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<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New()); builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
@@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) => private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
[Fact] [Fact]
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit() public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests
} }
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
} }
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются). // Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
private static FakeAuthStore NewUserStore() => new(); private static TestAuthStore NewUserStore() => new();
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
private static HttpClient CreateClient(string baseAddress) => private static HttpClient CreateClient(string baseAddress) =>
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit() public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const long newBudget = 20_000_000; const long newBudget = 20_000_000;
await RunAsync( await RunAsync(
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit() public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, _, _, _, _) => async (baseAddress, operatorStore, _, _, _, _, _) =>
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit() public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const long budget = 25_000_000; const long budget = 25_000_000;
await RunAsync( await RunAsync(
@@ -284,19 +284,19 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit"; $"{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; // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
new TenantRecordDto(SecondTenantId, SecondTenantName, 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(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true);
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
return OperatorAuthHttpHost.RunAsync( return OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
new FakeAuthStore(), new TestAuthStore(),
scenario, scenario,
auditStore, auditStore,
tenantStore: tenantStore, tenantStore: tenantStore,
@@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests
} }
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) => private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
[Fact] [Fact]
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) => async (baseAddress, operatorStore, _, globalSettings, audit) =>
@@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{baseAddress}/api/operator/settings/telegram-keys"; $"{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( OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(), NewOperatorStore(),
new FakeAuthStore(), new TestAuthStore(),
scenario, scenario,
auditStore); auditStore);
@@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
} }
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit() public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, tenantStore, _) => async (baseAddress, operatorStore, _, tenantStore, _) =>
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner() public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const string ownerEmail = "owner@created.com"; const string ownerEmail = "owner@created.com";
await RunAsync( await RunAsync(
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin() public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, userStore, tenantStore, _) => async (baseAddress, operatorStore, userStore, tenantStore, _) =>
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit() public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow)); new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit() public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, userStore, _, _) => async (baseAddress, operatorStore, userStore, _, _) =>
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync( private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null) TestTenantStore? tenantStore = null)
{ {
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore). // По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
@@ -503,8 +503,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором. // Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
private static void AssertStatusChangedAudit( private static void AssertStatusChangedAudit(
FakeAuditLogStore auditStore, TestAuditLogStore auditStore,
FakeOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
string status) string status)
{ {
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу. // В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
@@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
} }
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
OperatorLogin, OperatorLogin,
@@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
} }
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте. // Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
TenantUserLogin, TenantUserLogin,
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Infrastructure.Data; using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; 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.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
@@ -40,11 +40,11 @@ public sealed class PipelineWorkerSchedulerTests
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов. // Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
private sealed record Context( private sealed record Context(
PipelineWorkerScheduler Scheduler, PipelineWorkerScheduler Scheduler,
FakePipelineStore PipelineA, TestPipelineStore PipelineA,
FakeKanjStore KanjA, TestKanjStore KanjA,
SseSubscription SubscriptionA, SseSubscription SubscriptionA,
FakePipelineStore PipelineB, TestPipelineStore PipelineB,
FakeKanjStore KanjB, TestKanjStore KanjB,
SseSubscription SubscriptionB, SseSubscription SubscriptionB,
PipelinePumpGate PumpGate, PipelinePumpGate PumpGate,
TenantContext TenantContext, TenantContext TenantContext,
@@ -150,12 +150,12 @@ public sealed class PipelineWorkerSchedulerTests
{ {
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new FakePipelineStore(); var pipelineB = new TestPipelineStore();
var kanjA = new FakeKanjStore(); var kanjA = new TestKanjStore();
var kanjB = new FakeKanjStore(); var kanjB = new TestKanjStore();
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new TestSettingsStore();
var pumpGate = new PipelinePumpGate(); var pumpGate = new PipelinePumpGate();
var aiClassifier = new TestAiClassifier var aiClassifier = new TestAiClassifier
{ {
@@ -167,13 +167,13 @@ public sealed class PipelineWorkerSchedulerTests
services.AddSingleton<SseBroker>(); services.AddSingleton<SseBroker>();
services.AddSingleton<ITenantRepository>(tenants.Repository); services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton(pumpGate); services.AddSingleton(pumpGate);
services.AddSingleton<IMlClient>(new FakeMlClient()); services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier); services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store);
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddScoped<IncomingRules>(); services.AddScoped<IncomingRules>();
services.AddScoped<LocalFieldsParser>(); services.AddScoped<LocalFieldsParser>();
@@ -266,18 +266,7 @@ public sealed class PipelineWorkerSchedulerTests
return cardIds; return cardIds;
} }
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). // Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore.
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
}
}
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
private sealed class ListLogger : ILogger<PipelineWorkerScheduler> private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
@@ -1,12 +1,12 @@
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -24,29 +24,29 @@ public sealed class PipelineWorkerServiceTests
// Контекст теста: воркер поверх in-memory фейков хранилищ/портов. // Контекст теста: воркер поверх in-memory фейков хранилищ/портов.
private sealed record Context( private sealed record Context(
PipelineWorkerService Worker, PipelineWorkerService Worker,
FakePipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, TestKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
FakeMlClient MlClient, TestMlClient MlClient,
TestAiClassifier AiClassifier); TestAiClassifier AiClassifier);
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
private static Context CreateContext(FakePipelineStore? pipelineStore = null) private static Context CreateContext(TestPipelineStore? pipelineStore = null)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var store = pipelineStore ?? new FakePipelineStore(); var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new FakeKanjStore(); var kanjStore = new TestKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier(); var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store, composer); var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService( 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); return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
} }
@@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests
// видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел
// заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается.
// claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). // 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). // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (число/булево/строка/список). // value: Значение (число/булево/строка/список).
@@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests
[Fact] [Fact]
public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard()
{ {
Context ctx = CreateContext(new RacingClaimPipelineStore()); Context ctx = CreateContext(new TestPipelineStore(claimRace: true));
const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц";
ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); ctx.PipelineStore.SeedQueue(QueueRow("p_1", text));
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default);
RacingClaimPipelineStore store = Assert.IsType<RacingClaimPipelineStore>(ctx.PipelineStore); TestPipelineStore store = ctx.PipelineStore;
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("dup", rejected.Stage); Assert.Equal("dup", rejected.Stage);
@@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests
Assert.Empty(ctx.KanjStore.CardDtos); Assert.Empty(ctx.KanjStore.CardDtos);
Assert.Equal(1, result.NoBudget); Assert.Equal(1, result.NoBudget);
Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L11431147) 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)); Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions));
} }
@@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests
QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue);
Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился 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.PipelineStore.Rejected);
Assert.Empty(ctx.KanjStore.CardDtos); Assert.Empty(ctx.KanjStore.CardDtos);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using NSubstitute; using NSubstitute;
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
{ {
private const long FixedTimestampMs = 1_752_000_000_000; private const long FixedTimestampMs = 1_752_000_000_000;
private readonly FakeSettingsStore _store = new(); private readonly TestSettingsStore _store = new();
// ─── GetAsync: кэш и дефолт ───────────────────────────────────────────── // ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners) private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
{ {
return new RatesService(_store, source, listeners); return new RatesService(_store.Store, source, listeners);
} }
// JSON значения ratesCache с одним курсом USD и заданным временем/источником. // JSON значения ratesCache с одним курсом USD и заданным временем/источником.
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using NSubstitute; using NSubstitute;
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
/// </summary> /// </summary>
public sealed class SettingsServiceTests public sealed class SettingsServiceTests
{ {
private readonly FakeSettingsStore _store = new(); private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service; private readonly SettingsService _service;
public SettingsServiceTests() public SettingsServiceTests()
{ {
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>()); _service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
} }
// ─── GET: снимок ───────────────────────────────────────────────────────── // ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); 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() public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners() public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); 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() public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); 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); await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
@@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts() public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => store), services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel => async channel =>
{ {
PushSourceReply reply = await PushAsync( PushSourceReply reply = await PushAsync(
@@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown() public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => store), services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel => async channel =>
{ {
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
@@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown() public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => store), services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel => async channel =>
{ {
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken); PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
@@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError() public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<IPipelineStore>(_ => store), services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel => async channel =>
{ {
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken); PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
@@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_WithoutToken_IsUnauthenticated() public async Task PushSource_WithoutToken_IsUnauthenticated()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); 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>( RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null)); () => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
@@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_WithWrongToken_IsUnauthenticated() public async Task PushSource_WithWrongToken_IsUnauthenticated()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); 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>( RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token")); () => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
@@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_UnsetEnvToken_FailsClosed() public async Task PushSource_UnsetEnvToken_FailsClosed()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await TelegramIngressTestHost.RunAsync( await TelegramIngressTestHost.RunAsync(
@@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests
configureServices: services => configureServices: services =>
{ {
services.AddSingleton<ITenantRepository>(registry); services.AddSingleton<ITenantRepository>(registry);
services.AddScoped<IPipelineStore>(_ => store); services.AddScoped<IPipelineStore>(_ => store.Store);
}, },
scenario: async channel => scenario: async channel =>
{ {
@@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact] [Fact]
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated() public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
{ {
var store = new FakePipelineStore(); var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA)); 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>( RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken)); () => 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.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
@@ -42,9 +42,9 @@ public sealed class StorageTickSchedulerTests
[Fact] [Fact]
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
{ {
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
@@ -70,10 +70,10 @@ public sealed class StorageTickSchedulerTests
[Fact] [Fact]
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
{ {
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new FakeKanjStore(); var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -99,16 +99,16 @@ public sealed class StorageTickSchedulerTests
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
{ {
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore> var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{ {
[TenantA] = new ThrowingSettingsStore(), [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, [TenantB] = storeB,
}; };
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
@@ -137,7 +137,7 @@ public sealed class StorageTickSchedulerTests
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(), new ThrowingTenantRepository(),
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore>(), new Dictionary<Guid, TestKanjStore>(),
new Dictionary<Guid, ISettingsStore>()); new Dictionary<Guid, ISettingsStore>());
StorageTickScheduler scheduler = CreateScheduler(provider); StorageTickScheduler scheduler = CreateScheduler(provider);
@@ -150,19 +150,19 @@ public sealed class StorageTickSchedulerTests
[Fact] [Fact]
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
{ {
var kanjStore = new FakeKanjStore(); var kanjStore = new TestKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var pipelineStoreA = new FakePipelineStore(); var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, new Dictionary<Guid, TestKanjStore> { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings }, new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -181,18 +181,18 @@ public sealed class StorageTickSchedulerTests
[Fact] [Fact]
public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() 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_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000));
cardStoreA.SeedCard(HoldCard("c_a_future", 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)); 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(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -210,24 +210,24 @@ public sealed class StorageTickSchedulerTests
Assert.False(subscriptionB.Events.TryRead(out _)); Assert.False(subscriptionB.Events.TryRead(out _));
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). // «Выстрелившее» помечено 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); Assert.False(tenantContext.HasTenant);
} }
[Fact] [Fact]
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
{ {
var cardStoreA = new FakeKanjStore(); var cardStoreA = new TestKanjStore();
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository, new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA }, new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA }, new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -245,15 +245,15 @@ public sealed class StorageTickSchedulerTests
{ {
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new ThrowingDueKanjStore(); var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB }, new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA); SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -281,15 +281,15 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
TestTenantRepository tenants, TestTenantRepository tenants,
TenantContext tenantContext, TenantContext tenantContext,
FakeKanjStore storeA, TestKanjStore storeA,
FakeKanjStore storeB, TestKanjStore storeB,
FakeSettingsStore settings) TestSettingsStore settings)
{ {
return BuildProvider( return BuildProvider(
tenants.Repository, tenants.Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB }, new Dictionary<Guid, TestKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings }); new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
} }
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
@@ -302,14 +302,14 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
ITenantRepository tenants, ITenantRepository tenants,
TenantContext tenantContext, TenantContext tenantContext,
Dictionary<Guid, FakeKanjStore> storesByTenant, Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant, 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(), [TenantA] = new TestPipelineStore(),
[TenantB] = new FakePipelineStore(), [TenantB] = new TestPipelineStore(),
}; };
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -319,10 +319,10 @@ public sealed class StorageTickSchedulerTests
services.AddSingleton(tenants); services.AddSingleton(tenants);
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
// выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. // выбирает хранилище по тому же 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<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)]); services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
services.AddSingleton<IMlClient>(new FakeMlClient()); services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage); services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
services.AddScoped<PipelineIngestService>(); services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>(); services.AddScoped<PipelineProcessingService>();
@@ -359,17 +359,7 @@ public sealed class StorageTickSchedulerTests
private static TenantRecordDto Tenant(Guid id) => private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий // Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в
// AdminTickOrchestratorTests).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -394,9 +384,9 @@ public sealed class StorageTickSchedulerTests
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
// cardId: Id карточки. // cardId: Id карточки.
// Возвращает: Фейк-хранилище с одной старой карточкой inbox. // Возвращает: Фейк-хранилище с одной старой карточкой 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))); store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge)));
return store; return store;
} }
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Grpc.Telegram; using Deal.Grpc.Telegram;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
{ {
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AutoMonitorNew, "false"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
var dialogs = new TestTelegramStore(); var dialogs = new TestTelegramStore();
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
registry, registry,
services => services =>
{ {
services.AddScoped<ISettingsStore>(_ => settings); services.AddScoped<ISettingsStore>(_ => settings.Store);
services.AddScoped<ITelegramStore>(_ => dialogs.Store); services.AddScoped<ITelegramStore>(_ => dialogs.Store);
}, },
async channel => async channel =>
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
[Fact] [Fact]
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var broker = new SseBroker(); var broker = new SseBroker();
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
services => services =>
{ {
services.AddSingleton<SseBroker>(broker); services.AddSingleton<SseBroker>(broker);
services.AddScoped<ISettingsStore>(_ => settings); services.AddScoped<ISettingsStore>(_ => settings.Store);
}, },
async channel => async channel =>
{ {
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
[Fact] [Fact]
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<ISettingsStore>(_ => settings), services => services.AddScoped<ISettingsStore>(_ => settings.Store),
async channel => async channel =>
{ {
var client = new IngressService.IngressServiceClient(channel); var client = new IngressService.IngressServiceClient(channel);
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
// phase: Ожидаемая фаза. // phase: Ожидаемая фаза.
// connected: Ожидаемый флаг connected. // connected: Ожидаемый флаг connected.
private static void AssertStatusStored( private static void AssertStatusStored(
FakeSettingsStore settings, TestSettingsStore settings,
string phase, string phase,
bool connected) bool connected)
{ {
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
.AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); .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<ITelegramStore>(_ => new TestTelegramStore().Store);
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway); builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule(); builder.Services.AddTelegramModule();
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
var store = new TestTelegramStore(); var store = new TestTelegramStore();
var dialogs = new DialogsService( var dialogs = new DialogsService(
store.Store, store.Store,
new FakeSettingsStore(), new TestSettingsStore().Store,
new TestTelegramGateway().Gateway, new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance); NullLogger<DialogsService>.Instance);
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.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.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Cards; namespace Deal.Tests.Unit.Support;
/// <summary> /// <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> /// </summary>
public class FakeKanjStore : ICardStore public sealed class TestKanjStore
{ {
private readonly List<ContainerDto> _boards = []; private readonly List<ContainerDto> _boards = [];
private readonly List<CardDto> _cards = []; private readonly List<CardDto> _cards = [];
private readonly List<CardMoveDto> _moves = []; private readonly List<CardMoveDto> _moves = [];
private readonly List<(string Query, int Limit)> _searchCalls = []; private readonly List<(string Query, int Limit)> _searchCalls = [];
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal); private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal); private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
private readonly bool _throwOnDueReminders;
/// <summary> /// <summary>
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests. /// Подставка порта единого домена карточки (создаётся в конструкторе).
/// </summary>
public ICardStore Store { get; }
/// <summary>
/// Доски хранилища (копия на момент обращения).
/// </summary> /// </summary>
public IReadOnlyList<ContainerDto> Boards => _boards.ToList(); public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
/// <summary> /// <summary>
/// Карточки фейка как тройки /// Карточки хранилища как тройки (id, колонка, флаг «новая»).
/// </summary> /// </summary>
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards => public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList(); _cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
/// <summary> /// <summary>
/// Полные карточки фейка /// Полные карточки хранилища (копия на момент обращения).
/// </summary> /// </summary>
public IReadOnlyList<CardDto> CardDtos => _cards.ToList(); public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
/// <summary> /// <summary>
/// Флаг «сбой записи карточки» /// Флаг «сбой записи карточки».
/// </summary> /// </summary>
public bool FailAddCard { get; set; } public bool FailAddCard { get; set; }
/// <summary> /// <summary>
/// Записи журнала CardMoves /// Записи журнала CardMoves.
/// </summary> /// </summary>
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList(); public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
/// <summary> /// <summary>
/// Вызовы SearchCardsAsync как пары /// Вызовы SearchCardsAsync как пары (запрос, лимит).
/// </summary> /// </summary>
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList(); 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>
/// Кладёт доску напрямую /// Кладёт доску напрямую
/// </summary> /// </summary>
/// <param name="board">Доска как если бы была сохранена в БД.</param> /// <param name="board">Доска как если бы была сохранена в БД.</param>
public void SeedBoard(ContainerDto board) public void SeedBoard(ContainerDto board) => _boards.Add(board);
{
_boards.Add(board);
}
/// <summary> /// <summary>
/// Кладёт карточку в колонку. /// Кладёт карточку в колонку.
/// </summary> /// </summary>
/// <param name="cardId">Id карточки.</param> /// <param name="cardId">Id карточки.</param>
/// <param name="col">Колонка (inbox/доска).</param> /// <param name="col">Колонка (inbox/доска).</param>
public void AddCard(string cardId, string col) public void AddCard(string cardId, string col) => SeedCard(new CardDto { Id = cardId, Col = col });
{
SeedCard(new CardDto { Id = cardId, Col = col });
}
/// <summary> /// <summary>
/// Кладёт полную карточку. /// Кладёт полную карточку.
/// </summary> /// </summary>
/// <param name="card">Карточка как если бы была сохранена в БД (комментарии — приложенным массивом).</param> /// <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)));
} }
// ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── private void ConfigureCards()
/// <inheritdoc />
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
{ {
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) .Where(board => space is null || board.Space == space)
.OrderBy(board => board.Suggested) .OrderBy(board => board.Suggested)
.ThenBy(board => board.Order) .ThenBy(board => board.Order)
.ToList(); .ToList();
return Task.FromResult(ordered);
} }
/// <inheritdoc /> private void UpdateContainer(ContainerDto container)
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_boards.FirstOrDefault(board => board.Id == containerId));
}
/// <inheritdoc />
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
{
_boards.Add(container);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
{ {
int index = _boards.FindIndex(item => item.Id == container.Id); int index = _boards.FindIndex(item => item.Id == container.Id);
_boards[index] = container; _boards[index] = container;
return Task.CompletedTask;
} }
/// <inheritdoc /> private int DeleteContainer(string containerId)
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
{ {
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++) for (int i = 0; i < _cards.Count; i++)
{ {
if (_cards[i].Col == containerId) if (_cards[i].Col == containerId)
@@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore
} }
_boards.RemoveAll(board => board.Id == containerId); _boards.RemoveAll(board => board.Id == containerId);
return Task.FromResult(moved.Count); return moved;
} }
/// <inheritdoc /> private void ReorderContainers(string space, IReadOnlyList<string> containerIds)
public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
{ {
for (int i = 0; i < containerIds.Count; i++) for (int i = 0; i < containerIds.Count; i++)
{ {
string containerId = containerIds[i]; int index = _boards.FindIndex(board => board.Id == containerIds[i] && board.Space == space);
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
if (index >= 0) if (index >= 0)
{ {
_boards[index] = _boards[index] with { Order = i }; _boards[index] = _boards[index] with { Order = i };
} }
} }
return Task.CompletedTask;
} }
// ── Карточки ───────────────────────────────────────────────────────── private IReadOnlyList<CardDto> ListCards(CardsQuery query)
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
{ {
IEnumerable<CardDto> result = query.Col is null IEnumerable<CardDto> result = query.Col is null
? _cards ? _cards
: _cards.Where(card => card.Col == query.Col); : _cards.Where(card => card.Col == query.Col);
return Task.FromResult<IReadOnlyList<CardDto>>( return result.OrderByDescending(card => card.ReceivedAtMs).ToList();
result.OrderByDescending(card => card.ReceivedAtMs).ToList());
} }
/// <inheritdoc /> private IReadOnlyList<CardDto> SearchCards(string q, int limit)
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
{ {
_searchCalls.Add((q, limit)); _searchCalls.Add((q, limit));
string lowered = q.Trim().ToLowerInvariant(); string lowered = q.Trim().ToLowerInvariant();
if (lowered.Length == 0) 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) .Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
@@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.Take(limit) .Take(limit)
.ToList(); .ToList();
return Task.FromResult<IReadOnlyList<CardDto>>(result);
} }
/// <inheritdoc /> private CardDto? GetCardBySource(SourceRef source)
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return Task.FromResult(_cards.FirstOrDefault(card => card.Id == cardId));
}
/// <inheritdoc />
public Task<CardDto?> GetCardBySourceAsync(
SourceRef source,
CancellationToken ct)
{ {
if (string.IsNullOrEmpty(source.Kind)) if (string.IsNullOrEmpty(source.Kind))
{ {
return Task.FromResult<CardDto?>(null); return null;
} }
string externalId = source.ExternalId ?? string.Empty; string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty; string originRef = source.OriginRef ?? string.Empty;
CardDto? match = _cards return _cards
.Where(card => card.Source.Kind == source.Kind .Where(card => card.Source.Kind == source.Kind
&& (card.Source.ExternalId ?? string.Empty) == externalId && (card.Source.ExternalId ?? string.Empty) == externalId
&& (card.Source.OriginRef ?? string.Empty) == originRef) && (card.Source.OriginRef ?? string.Empty) == originRef)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.FirstOrDefault(); .FirstOrDefault();
return Task.FromResult(match);
} }
/// <inheritdoc /> private void AddCard(CardSnapshot snapshot)
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
{ {
if (FailAddCard) if (FailAddCard)
{ {
@@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}); });
return Task.CompletedTask;
} }
/// <inheritdoc /> private void UpdateColumn(CardColumnUpdateDto update)
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == update.CardId); int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0) if (index < 0)
{ {
return Task.CompletedTask; return;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore
{ {
_archivedAtById.Remove(update.CardId); _archivedAtById.Remove(update.CardId);
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private bool ApplyReclassification(CardReclassificationDto update)
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == update.CardId); int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore
Contacts = update.Contacts, Contacts = update.Contacts,
MatchHits = update.MatchHits, MatchHits = update.MatchHits,
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private void UpdateSeen(string? cardId, string? col)
public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{ {
for (int i = 0; i < _cards.Count; i++) for (int i = 0; i < _cards.Count; i++)
{ {
bool matches = cardId is not null bool matches = cardId is not null ? _cards[i].Id == cardId : col is null || _cards[i].Col == col;
? _cards[i].Id == cardId
: col is null || _cards[i].Col == col;
if (matches && _cards[i].IsNew) if (matches && _cards[i].IsNew)
{ {
_cards[i] = _cards[i] with { IsNew = false }; _cards[i] = _cards[i] with { IsNew = false };
} }
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private void DeleteForever(string cardId)
public Task DeleteForeverAsync(string cardId, CancellationToken ct)
{ {
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
_cards.RemoveAll(card => card.Id == cardId); _cards.RemoveAll(card => card.Id == cardId);
_archivedAtById.Remove(cardId); _archivedAtById.Remove(cardId);
return Task.CompletedTask;
} }
/// <inheritdoc /> private int ClearCol(string col)
public Task<int> ClearColAsync(string col, CancellationToken ct)
{ {
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList(); List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
_cards.RemoveAll(card => card.Col == col); _cards.RemoveAll(card => card.Col == col);
@@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId); _archivedAtById.Remove(cardId);
} }
return Task.FromResult(removedIds.Count); return removedIds.Count;
} }
/// <inheritdoc /> private IReadOnlyDictionary<string, CardColumnCountDto> CountCardsByCol()
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
{ {
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards return _cards
.GroupBy(card => card.Col) .GroupBy(card => card.Col)
.ToDictionary( .ToDictionary(
group => group.Key, group => group.Key,
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew))); group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
return Task.FromResult(result);
} }
// ── Комментарии (LeadComments) ──────────────────────────────────────── private void AddComment(string commentId, string cardId, string by, string text)
/// <inheritdoc />
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
{
CardDto? card = _cards.FirstOrDefault(item => item.Id == cardId);
return Task.FromResult(card?.Comments ?? Array.Empty<CardCommentDto>());
}
/// <inheritdoc />
public Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.CompletedTask; return;
} }
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow). // Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
CardCommentDto comment = new(commentId, by, text, "только что"); CardCommentDto comment = new(commentId, by, text, "только что");
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList(); IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
_cards[index] = _cards[index] with { Comments = updated }; _cards[index] = _cards[index] with { Comments = updated };
return Task.CompletedTask;
} }
// ── Журнал CardMoves (learning_log) ─────────────────────────────────── private IReadOnlyList<AiMarkupExampleDto> GetAiMarkupExamples(int limit)
/// <inheritdoc />
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
_moves.Add(move);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountMovesAsync(CancellationToken ct) => Task.FromResult(_moves.Count);
/// <inheritdoc />
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{ {
var examples = new List<AiMarkupExampleDto>(); var examples = new List<AiMarkupExampleDto>();
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--) 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)); examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
} }
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples); return examples;
} }
private IReadOnlyList<string> ListArchiveCandidates(DateTimeOffset receivedBeforeUtc)
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{ {
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal); 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)) .Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col))
&& ReceivedAtOf(card) < receivedBeforeUtc) && ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id) .Select(card => card.Id)
.ToList(); .ToList();
return Task.FromResult(result);
} }
/// <inheritdoc /> private int Archive(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt)
public Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct)
{ {
if (cardIds.Count == 0) if (cardIds.Count == 0)
{ {
return Task.FromResult(0); return 0;
} }
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal); var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore
archived++; archived++;
} }
return Task.FromResult(archived); return archived;
} }
/// <inheritdoc /> private IReadOnlyList<string> ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc)
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
{ {
IReadOnlyList<string> result = _cards return _cards
.Where(card => card.Col == KanbanColumns.Archive .Where(card => card.Col == KanbanColumns.Archive
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt) && _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
&& archivedAt < archivedBeforeUtc) && archivedAt < archivedBeforeUtc)
.Select(card => card.Id) .Select(card => card.Id)
.ToList(); .ToList();
return Task.FromResult(result);
} }
/// <inheritdoc /> private IReadOnlyList<string> ListTrashCandidates(DateTimeOffset receivedBeforeUtc)
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{ {
IReadOnlyList<string> result = _cards return _cards
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc) .Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id) .Select(card => card.Id)
.ToList(); .ToList();
return Task.FromResult(result);
} }
/// <inheritdoc /> private int Purge(IReadOnlyList<string> cardIds)
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{ {
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
if (cardIds.Count == 0) if (cardIds.Count == 0)
{ {
return Task.FromResult(0); return 0;
} }
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal); var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId); _archivedAtById.Remove(cardId);
} }
return Task.FromResult(removed); return removed;
} }
/// <summary> private IReadOnlyList<CardDto> ListCardsForConversion()
/// Задаёт метку архивации карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
/// <summary>
/// Метка архивации карточки — проверка archived_at после автоархива тика.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
public DateTimeOffset? ArchivedAtOf(string cardId)
{ {
return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null; return _cards
}
// Момент получения карточки из её epoch-ms (как маппинг адаптера ReceivedAt → ReceivedAtMs).
// card: Карточка.
// Возвращает: ReceivedAt карточки как UTC-момент.
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> result = _cards
.Where(card => card.Budget is not null .Where(card => card.Budget is not null
&& card.Col != KanbanColumns.Archive && card.Col != KanbanColumns.Archive
&& card.Col != KanbanColumns.Trash) && card.Col != KanbanColumns.Trash)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.ToList(); .ToList();
return Task.FromResult(result);
} }
/// <inheritdoc /> private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur)
public Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0) if (index >= 0)
{ {
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
{ {
Converted = convCur.Length == 0 Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur),
? null
: new CardBudgetDto(convFrom, convTo, convCur),
}; };
} }
return Task.CompletedTask;
} }
private IReadOnlyList<CardDto> ListSelectedCards(string? containerId)
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{ {
IEnumerable<CardDto> query = containerId is null IEnumerable<CardDto> query = containerId is null
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col)) ? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
: _cards.Where(card => card.Col == containerId); : _cards.Where(card => card.Col == containerId);
return Task.FromResult<IReadOnlyList<CardDto>>( return query.OrderByDescending(card => card.UpdatedAtMs).ToList();
query.OrderByDescending(card => card.UpdatedAtMs).ToList());
} }
/// <inheritdoc /> private bool PatchCard(string cardId, CardPatch patch)
public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
CardDto card = _cards[index]; CardDto card = _cards[index];
@@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore
} }
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; _cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private bool AddLink(string cardId, CardLinkDto link)
public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore
Links = [.. _cards[index].Links, link], Links = [.. _cards[index].Links, link],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private bool RemoveLink(string cardId, string linkId)
public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(), Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private bool AddFile(string cardId, CardFileDto file)
public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore
Files = [.. _cards[index].Files, file], Files = [.. _cards[index].Files, file],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private bool RemoveFile(string cardId, string fileId)
public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
_cards[index] = _cards[index] with _cards[index] = _cards[index] with
@@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(), Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs)
public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.FromResult(false); return false;
} }
CardDto card = _cards[index]; CardDto card = _cards[index];
@@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore
UpdatedAtMs = atMs, UpdatedAtMs = atMs,
History = [.. card.History, historyEntry], History = [.. card.History, historyEntry],
}; };
return Task.FromResult(true); return true;
} }
/// <inheritdoc /> private void SetReminder(string cardId, long atMs)
public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0) if (index < 0)
{ {
return Task.CompletedTask; return;
} }
_firedById.Remove(cardId); _firedById.Remove(cardId);
@@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore
Reminder = new CardReminderDto(atMs), Reminder = new CardReminderDto(atMs),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}; };
return Task.CompletedTask;
} }
/// <inheritdoc /> private void ClearReminder(string cardId)
public Task ClearReminderAsync(string cardId, CancellationToken ct)
{ {
int index = _cards.FindIndex(card => card.Id == cardId); int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0) if (index >= 0)
@@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore
} }
_firedById.Remove(cardId); _firedById.Remove(cardId);
return Task.CompletedTask;
} }
/// <inheritdoc /> private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId);
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
}
/// <inheritdoc /> private IReadOnlyList<CardReminderDueDto> ListDueReminders(DateTimeOffset now)
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{ {
long nowMs = now.ToUnixTimeMilliseconds(); long nowMs = now.ToUnixTimeMilliseconds();
IReadOnlyList<CardReminderDueDto> due = _cards return _cards
.Where(card => card.Col == CardsDefaultContainers.Hold .Where(card => card.Col == CardsDefaultContainers.Hold
&& card.Reminder is not null && card.Reminder is not null
&& !_firedById.Contains(card.Id) && !_firedById.Contains(card.Id)
@@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore
.OrderBy(card => card.Reminder!.At) .OrderBy(card => card.Reminder!.At)
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col)) .Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
.ToList(); .ToList();
return Task.FromResult(due);
} }
/// <inheritdoc /> private void MarkRemindersFired(IReadOnlyList<string> cardIds)
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{ {
foreach (string cardId in cardIds) foreach (string cardId in cardIds)
{ {
@@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore
_firedById.Add(cardId); _firedById.Add(cardId);
} }
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private int ClearExpiredReminders(DateTimeOffset now)
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
{ {
long nowMs = now.ToUnixTimeMilliseconds(); long nowMs = now.ToUnixTimeMilliseconds();
int cleared = 0; int cleared = 0;
@@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore
} }
} }
return Task.FromResult(cleared); return cleared;
} }
/// <inheritdoc /> private IReadOnlyList<CardDto> ListInboxWithSource()
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{ {
IReadOnlyList<CardDto> result = _cards return _cards
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty) .Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.ToList(); .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.Abstractions;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Support;
/// <summary> /// <summary>
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump. /// Подставка <see cref="IMlClient"/>: сервисы получают NSubstitute-подставку (<see cref="Client"/>),
/// тесты управляют сценариями через свойства (как раньше — через фейк).
/// </summary> /// </summary>
public sealed class FakeMlClient : IMlClient public sealed class TestMlClient
{ {
private readonly List<(string Text, string Label, double Delta)> _pushed = []; private readonly List<(string Text, string Label, double Delta)> _pushed = [];
/// <summary>
/// Подставка порта ML-клиента (создаётся в конструкторе).
/// </summary>
public IMlClient Client { get; }
/// <summary> /// <summary>
/// Ответ StatusAsync /// Ответ StatusAsync
/// </summary> /// </summary>
@@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient
public int PredictCalls { get; private set; } public int PredictCalls { get; private set; }
/// <summary> /// <summary>
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/> /// Обучающие сигналы, отправленные через <see cref="IMlClient.PushAsync"/>
/// </summary> /// </summary>
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList(); public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
/// <inheritdoc /> /// <summary>
public Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) => Task.FromResult(Status); /// Создаёт подставку с поведением по умолчанию.
/// </summary>
/// <inheritdoc /> public TestMlClient()
public Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
{ {
_pushed.Add((text, label, delta)); Client = Substitute.For<IMlClient>();
return Task.CompletedTask; 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>()))
/// <inheritdoc /> .Do(ci => _pushed.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<double>(2))));
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct) Client.PredictAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<MlPredictResultDto>(ci =>
{ {
PredictCalls++; PredictCalls++;
return Predict is null return Predict ?? throw new NotSupportedException();
? throw new NotSupportedException() });
: Task.FromResult(Predict); Client.ResetAsync(Arg.Any<CancellationToken>())
.Returns<MlResetResultDto>(_ => throw new NotSupportedException());
} }
/// <inheritdoc />
public Task<MlResetResultDto> ResetAsync(CancellationToken ct) =>
throw new NotSupportedException();
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto). // Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий. // Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
private static MlStatusResponseDto DefaultStatus() 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> /// </summary>
public sealed class TokenUsageRecorderTests public sealed class TokenUsageRecorderTests
{ {
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore. // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() 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); 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] [Fact]
public async Task AddAsync_AccumulatesAcrossCalls() 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 = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "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] [Fact]
public async Task AddAsync_NullUsage_IsNoop() 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); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits() 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 и событие пишутся (вызов состоялся), строка лимита // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_OutsideTenantContext_Throws() public async Task AddAsync_OutsideTenantContext_Throws()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
var recorder = new TokenUsageRecorder( 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>( await Assert.ThrowsAsync<InvalidOperationException>(
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None)); () => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate() 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-счётчик не трогаются. // 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
long estimated = await recorder.AddEstimatedAsync( long estimated = await recorder.AddEstimatedAsync(
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
// Возвращает: Кортеж (настройки, лимиты, история, recorder). // Возвращает: Кортеж (настройки, лимиты, история, 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 settings = new TestSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new TestTenantLimitStore();
var events = new TestTokenUsageEventStore(); var events = new TestTokenUsageEventStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); 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); return (settings, limits, events, recorder);
} }
} }