diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d170780..bcb91ed 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,10 +1,27 @@ name: ci +# Прогоняем CI в двух случаях: +# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize). +# 2) Влитие в main (пуш в main — в т.ч. мерж МР). +# Обычные коммиты в ветки без открытого МР CI не запускают. on: push: + branches: + - main pull_request: + types: + - opened + - synchronize + - reopened workflow_dispatch: +# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий: +# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил. +# Завершённые (успех/фейл) прогоны не трогаются. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: build-test: runs-on: ubuntu-latest diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs index ef39851..9ed88c7 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs @@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard /// /// Хранилище Discovery (счётчик DiscLog за сутки). /// KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused). - /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — . + /// Источник текущего времени (UTC). По умолчанию — . public DiscoveryBanGuard( IDiscoveryStore store, ISettingsStore settings, diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index 9ba33ff..60b1d53 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() { DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); - await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); + var audit = new TestAuditLogStore(); + await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); + await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); @@ -63,9 +63,9 @@ public sealed class DataRetentionSchedulerTests public async Task RunCycle_IsIdempotent() { DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); - var limits = new FakeTenantLimitStore(); + var audit = new TestAuditLogStore(); + await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); + var limits = new TestTenantLimitStore(); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); DataRetentionScheduler scheduler = new( @@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests // counters: Хелпер счётчиков (окна посеяны сценарием). // Возвращает: Провайдер с сервисами цикла. private static ServiceProvider BuildProvider( - FakeAuditLogStore audit, - FakeTenantLimitStore limits, + TestAuditLogStore audit, + TestTenantLimitStore limits, TestRateLimitCounterStore counters) { var services = new ServiceCollection(); - services.AddScoped(_ => audit); - services.AddScoped(_ => limits); + services.AddScoped(_ => audit.Store); + services.AddScoped(_ => limits.Store); services.AddScoped(_ => counters.Store); return services.BuildServiceProvider(); } diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs index e954560..8e12fd4 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs @@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests var tenantContext = new TenantContext(); var storeA = new TestDiscoveryStore(); var storeB = new TestDiscoveryStore(); - var settingsA = new FakeSettingsStore(); - var settingsB = new FakeSettingsStore(); + var settingsA = new TestSettingsStore(); + var settingsB = new TestSettingsStore(); var gatewayA = new TestDiscoveryGateway(); var gatewayB = new TestDiscoveryGateway(); PreloadDiscoveryEvalOff(settingsA); @@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests var services = new ServiceCollection(); services.AddSingleton(tenantContext); services.AddSingleton(tenants.Repository); - services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new TestMlClient().Client); services.AddSingleton(_ => TestAiTools.New()); // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). services.AddScoped(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store); - services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store); services.AddScoped(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway); // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. services.AddDiscoveryModule(); @@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests } // ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам). - private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings) + private static void PreloadDiscoveryEvalOff(TestSettingsStore settings) { settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false"); diff --git a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs index da58789..7928bdb 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs @@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests await RunAsync( registry, - services => services.AddScoped(_ => new FakePipelineStore()), + services => services.AddScoped(_ => new TestPipelineStore().Store), async channel => { PushSourceReply first = await PushAsync(channel, TenantA); @@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests await RunAsync( registry, - services => services.AddScoped(_ => new FakePipelineStore()), + services => services.AddScoped(_ => new TestPipelineStore().Store), async channel => { // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs index 3986016..ef50d41 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { - var store = new FakeMlLearningStore(); + var store = new TestMlLearningStore(); SeedRows(store, count: 25, prefix: "a"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( port, new TestTenantRepository(Tenant(TenantA)), tenantContext, - new Dictionary { [TenantA] = store }); + new Dictionary { [TenantA] = store }); MlOutboxFlushScheduler scheduler = CreateScheduler(provider); await scheduler.RunCycleAsync(CancellationToken.None); @@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests // 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха. Assert.Equal(3, service.TrainCalls); Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); - Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(0, await store.Store.CountOutboxAsync(CancellationToken.None)); Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход }); } @@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.TrainUnavailable = true; - var store = new FakeMlLearningStore(); + var store = new TestMlLearningStore(); SeedRows(store, count: 5, prefix: "a"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( port, new TestTenantRepository(Tenant(TenantA)), tenantContext, - new Dictionary { [TenantA] = store }); + new Dictionary { [TenantA] = store }); MlOutboxFlushScheduler scheduler = CreateScheduler(provider); await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались + Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); // строки остались // Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены). await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(2, service.TrainCalls); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); Assert.False(tenantContext.HasTenant); }); } @@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { - var storeA = new FakeMlLearningStore(); + var storeA = new TestMlLearningStore(); SeedRows(storeA, count: 12, prefix: "a"); - var storeB = new FakeMlLearningStore(); + var storeB = new TestMlLearningStore(); SeedRows(storeB, count: 3, prefix: "b"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( port, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), tenantContext, - new Dictionary { [TenantA] = storeA, [TenantB] = storeB }); + new Dictionary { [TenantA] = storeA, [TenantB] = storeB }); MlOutboxFlushScheduler scheduler = CreateScheduler(provider); await scheduler.RunCycleAsync(CancellationToken.None); @@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests // Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены. Assert.Equal(3, service.TrainCalls); Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); - Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None)); - Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None)); Assert.False(tenantContext.HasTenant); }); } @@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { - var store = new FakeMlLearningStore(); + var store = new TestMlLearningStore(); SeedRows(store, count: 105, prefix: "a"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( port, new TestTenantRepository(Tenant(TenantA)), tenantContext, - new Dictionary { [TenantA] = store }); + new Dictionary { [TenantA] = store }); MlOutboxFlushScheduler scheduler = CreateScheduler(provider); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(10, service.TrainCalls); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); }); } @@ -149,7 +149,7 @@ public sealed class MlOutboxFlushSchedulerTests int port, TestTenantRepository tenants, TenantContext tenantContext, - Dictionary storesByTenant) + Dictionary storesByTenant) { var services = new ServiceCollection(); services.AddSingleton(tenantContext); @@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(provider => provider.GetRequiredService()); // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. - services.AddScoped(provider => storesByTenant[TenantOf(provider)]); - services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(provider => storesByTenant[TenantOf(provider)].Store); + services.AddScoped(_ => new TestSettingsStore().Store); + services.AddScoped(_ => new TestTenantLimitStore().Store); services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddLogging(); @@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests // count: Число строк. // prefix: Префикс id (различает тенантов теста). private static void SeedRows( - FakeMlLearningStore store, + TestMlLearningStore store, int count, string prefix) { diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs index 0be3a42..ff6bb2b 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs @@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); var passwordHasher = TestHashers.New(); var logger = new ListLogger(); // Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService): // провайдер собирается с реальной регистрацией модуля поверх фейков. var services = new ServiceCollection(); - services.AddScoped(_ => store); + services.AddScoped(_ => store.Store); services.AddScoped(_ => passwordHasher); services.AddScoped(); // Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания. @@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests // Контекст теста bootstrap-шага. private sealed record Context( OperatorBootstrapHostedService Hosted, - FakeOperatorAuthStore Store, + TestOperatorAuthStore Store, IPasswordHasher PasswordHasher, ListLogger Logs); diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 6a46433..e4c14ac 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() { - var pipelineByTenant = new Dictionary + var pipelineByTenant = new Dictionary { - [TenantA.ToString("N")] = new FakePipelineStore(), - [TenantB.ToString("N")] = new FakePipelineStore(), + [TenantA.ToString("N")] = new TestPipelineStore(), + [TenantB.ToString("N")] = new TestPipelineStore(), }; pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); - var outboxByTenant = new Dictionary + var outboxByTenant = new Dictionary { - [TenantA.ToString("N")] = new FakeMlLearningStore(), - [TenantB.ToString("N")] = new FakeMlLearningStore(), + [TenantA.ToString("N")] = new TestMlLearningStore(), + [TenantB.ToString("N")] = new TestMlLearningStore(), }; outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); @@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_CollectsBudgetRatiosPerTenant() { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); DateTimeOffset periodStart = DateTimeOffset.UtcNow; limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); @@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests PipelineStores(TenantA, TenantB, TenantC), OutboxStores(TenantA, TenantB, TenantC), TenantRecords(TenantA, TenantB, TenantC), - limits); + limits.Store); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); @@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); limits.FailStateReads.Add(TenantA); limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); @@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests PipelineStores(TenantA, TenantB), OutboxStores(TenantA, TenantB), TenantRecords(TenantA, TenantB), - limits); + limits.Store); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); @@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests public async Task CollectAsync_NoTenants_ReturnsZeros() { RuntimeDepthsCollector collector = Build( - new Dictionary(), - new Dictionary(), + new Dictionary(), + new Dictionary(), tenants: Array.Empty()); RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); @@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests Status = PipelineQueueStatuses.New, }; - private static Dictionary PipelineStores(params Guid[] tenants) - => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore()); + private static Dictionary PipelineStores(params Guid[] tenants) + => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore()); - private static Dictionary OutboxStores(params Guid[] tenants) - => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore()); + private static Dictionary OutboxStores(params Guid[] tenants) + => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore()); private static TenantRecordDto[] TenantRecords(params Guid[] tenants) => tenants @@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). private static RuntimeDepthsCollector Build( - IReadOnlyDictionary pipelineByTenant, - IReadOnlyDictionary outboxByTenant, + IReadOnlyDictionary pipelineByTenant, + IReadOnlyDictionary outboxByTenant, IReadOnlyList? tenants = null, ITenantLimitStore? limitStore = null) { @@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), }).ToArray()).Repository); - services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); - services.AddScoped(_ => new FakeMlClient()); + services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)].Store); + services.AddScoped(_ => new TestMlClient().Client); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); - services.AddSingleton(limitStore ?? new FakeTenantLimitStore()); + services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)].Store); + services.AddSingleton(limitStore ?? new TestTenantLimitStore().Store); ServiceProvider provider = services.BuildServiceProvider(); return new RuntimeDepthsCollector( diff --git a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs index a8ad167..cc45382 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs @@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests [Fact] public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() { - (TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); + (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); @@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests [Fact] public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() { - (TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); + (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); @@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests // ─── Хелперы ──────────────────────────────────────────────────────────── // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher). - private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() + private static (TgStatusService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() { var store = new TestTelegramStore(); - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); var gateway = new TestTelegramGateway(); var cipher = TestCiphers.New(); var globalSettings = new TestGlobalSettingsStore(); var keys = new TelegramKeysService(globalSettings.Store, cipher); - var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger.Instance); - var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys); + var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger.Instance); + var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys); return (service, store, settings, gateway, cipher, globalSettings); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs index 5956995..ff1307c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts; /// public sealed class BudgetedAiClassifierTests { - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; // Guid того же тенанта — ключ строки лимита. @@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); - var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings)); + var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store)); const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$"; AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); @@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests private sealed record Context( IAiClassifier Decorator, TestAiClassifier Paid, - FakeSettingsStore Settings); + TestSettingsStore Settings); // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст // (зеркало регистрации AddDealIntegrations при UseLocal=false). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) + private static Context Create(Action? configure = null) { - var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var settings = new TestSettingsStore(); + var limits = new TestTenantLimitStore(); configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var paid = new TestAiClassifier(); IAiClassifier decorator = new BudgetedAiClassifier( paid.Classifier, - new LocalAiClassifier(new LocalFieldsParser(settings)), - limits, + new LocalAiClassifier(new LocalFieldsParser(settings.Store)), + limits.Store, tenantContext, NullLogger.Instance); return new Context(decorator, paid, settings); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs index daac8df..5a5b6dd 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts; /// public sealed class BudgetedAiToolsTests { - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; // Guid того же тенанта — ключ строки лимита. @@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests // AddDealIntegrations при UseLocal=false). // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) + private static Context Create(Action? configure = null) { - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var paid = TestAiTools.New(); IAiTools decorator = new BudgetedAiTools( paid, - limits, + limits.Store, tenantContext, NullLogger.Instance); return new Context(decorator, paid); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs index 5967b56..006b11c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs @@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Support; @@ -109,7 +108,7 @@ public sealed class CardComposerTests [Fact] public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits() { - (CardComposer composer, FakeKanjStore store, _) = Create(); + (CardComposer composer, TestKanjStore store, _) = Create(); store.SeedBoard(new ContainerDto { Id = "b_py" }); CardSnapshot snapshot = await composer.BuildAsync( @@ -125,7 +124,7 @@ public sealed class CardComposerTests [Fact] public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox() { - (CardComposer composer, FakeKanjStore store, _) = Create(); + (CardComposer composer, TestKanjStore store, _) = Create(); store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" })); CardSnapshot snapshot = await composer.BuildAsync( @@ -141,7 +140,7 @@ public sealed class CardComposerTests [Fact] public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits() { - (CardComposer composer, FakeKanjStore store, _) = Create(); + (CardComposer composer, TestKanjStore store, _) = Create(); store.SeedBoard(BoardWithRules("b_py", new[] { "python" })); CardSnapshot snapshot = await composer.BuildAsync( @@ -193,7 +192,7 @@ public sealed class CardComposerTests [Fact] public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty() { - (CardComposer composer, _, FakeSettingsStore settings) = Create(); + (CardComposer composer, _, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348–349 CardSnapshot snapshot = await composer.BuildAsync( @@ -232,11 +231,11 @@ public sealed class CardComposerTests // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). // Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки). - private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create() + private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new CardComposer(store, settings), store, settings); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + return (new CardComposer(store.Store, settings.Store), store, settings); } // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index 8ae48b4..717e77e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Contracts; @@ -17,7 +16,7 @@ public sealed class CardsServiceTests [Fact] public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100)); store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300)); @@ -30,7 +29,7 @@ public sealed class CardsServiceTests [Fact] public async Task ListCards_ByColumn_ReturnsOnlyColumnCards() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); store.SeedCard(Card("l_2", KanbanColumns.Trash)); @@ -43,7 +42,7 @@ public sealed class CardsServiceTests [Fact] public async Task GetCard_Existing_ReturnsCard() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Middle Python")); CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None); @@ -66,7 +65,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$.")); @@ -84,7 +83,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToBoard_WritesJournalAndPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); @@ -105,7 +104,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToInbox_WritesJournalWithoutPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox")); @@ -124,7 +123,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToUnknownBoard_Returns400Text() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None); @@ -148,7 +147,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_CardMissing_ReturnsNullLead() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); @@ -162,7 +161,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_SameColumn_NoOpWithoutJournalAndPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true)); @@ -179,7 +178,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_free")); // правил нет store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст")); @@ -195,7 +194,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" }))); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox")); @@ -214,7 +213,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " ")); @@ -228,7 +227,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_EmptySourceAndTitle_JournalWithoutPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", "inbox")); @@ -241,7 +240,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_mid", Rules( grade: new[] { "middle" }, budget: new BudgetRangeDto(1000, 3000, "USD")))); @@ -263,7 +262,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_FromArchive_Returns400AndWritesNothing() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py")); @@ -279,7 +278,7 @@ public sealed class CardsServiceTests [Fact] public async Task Move_FromTrash_Returns400AndWritesNothing() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py")); @@ -296,7 +295,7 @@ public sealed class CardsServiceTests [Fact] public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true)); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); @@ -318,7 +317,7 @@ public sealed class CardsServiceTests [Fact] public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст")); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); @@ -331,7 +330,7 @@ public sealed class CardsServiceTests [Fact] public async Task Trash_FromArchive_WritesJournalWithoutPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); @@ -346,7 +345,7 @@ public sealed class CardsServiceTests [Fact] public async Task Trash_CardMissing_ReturnsNull() { - (CardsService service, _, _, FakeMlClient ml) = Create(); + (CardsService service, _, _, TestMlClient ml) = Create(); CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); @@ -358,7 +357,7 @@ public sealed class CardsServiceTests [Fact] public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py")); @@ -383,7 +382,7 @@ public sealed class CardsServiceTests [Fact] public async Task Restore_FromArchive_NoPush() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); @@ -399,7 +398,7 @@ public sealed class CardsServiceTests [Fact] public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); @@ -414,7 +413,7 @@ public sealed class CardsServiceTests [Fact] public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); @@ -439,7 +438,7 @@ public sealed class CardsServiceTests [Fact] public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")])); @@ -454,7 +453,7 @@ public sealed class CardsServiceTests [Fact] public async Task DeleteForever_JournalRowsSurviveCardDeletion() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); @@ -480,7 +479,7 @@ public sealed class CardsServiceTests [Fact] public async Task ClearCol_Trash_ReturnsClearedCount() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash)); store.SeedCard(Card("l_2", KanbanColumns.Trash)); store.SeedCard(Card("l_3", KanbanColumns.Archive)); @@ -507,7 +506,7 @@ public sealed class CardsServiceTests [Fact] public async Task ClearCol_Board_Returns400Text() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", "b_py")); @@ -531,7 +530,7 @@ public sealed class CardsServiceTests [Fact] public async Task MarkSeen_ById_OnlyThatCard() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true)); @@ -544,7 +543,7 @@ public sealed class CardsServiceTests [Fact] public async Task MarkSeen_ByCol_OnlyColumnCards() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true)); store.SeedCard(Card("l_3", "b_py", isNew: true)); @@ -558,7 +557,7 @@ public sealed class CardsServiceTests [Fact] public async Task MarkSeen_All_WithoutParameters() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true)); @@ -571,7 +570,7 @@ public sealed class CardsServiceTests [Fact] public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None); @@ -585,7 +584,7 @@ public sealed class CardsServiceTests [Fact] public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None); @@ -618,7 +617,7 @@ public sealed class CardsServiceTests [Fact] public async Task Counts_ColumnsNewAndMlStats() { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox")); store.SeedCard(Card("l_3", "b_py")); @@ -652,7 +651,7 @@ public sealed class CardsServiceTests [Fact] public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик")); IReadOnlyList one = await service.SearchCardsAsync("p", CancellationToken.None); @@ -666,7 +665,7 @@ public sealed class CardsServiceTests [Fact] public async Task Search_DelegatesToStoreWithQueryAndLimit() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); IReadOnlyList result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None); @@ -681,7 +680,7 @@ public sealed class CardsServiceTests [Fact] public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200)); store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100)); @@ -696,7 +695,7 @@ public sealed class CardsServiceTests [Fact] public async Task Search_LimitIsTwelve() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); for (int i = 0; i < 15; i++) { store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i)); @@ -711,12 +710,12 @@ public sealed class CardsServiceTests // ─── Хелперы ────────────────────────────────────────────────────────── - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() + private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var ml = new FakeMlClient(); - return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + var ml = new TestMlClient(); + return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml); } // Доска с правилами (ContainerRulesDto) либо без них. diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs index c32a759..02c7e24 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs @@ -38,7 +38,7 @@ public sealed class DialogsServiceTests [Fact] public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() { - (DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create(); + (DialogsService service, TestTelegramStore store, TestSettingsStore settings, _) = Create(); settings.Preload(SettingsKeys.AutoMonitorNew, "false"); int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); @@ -497,12 +497,12 @@ public sealed class DialogsServiceTests // ─── Хелперы ──────────────────────────────────────────────────────────── // Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые). - private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create() + private static (DialogsService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway) Create() { var store = new TestTelegramStore(); - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); var gateway = new TestTelegramGateway(); - return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger.Instance), store, settings, gateway); + return (new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger.Instance), store, settings, gateway); } // Строка каталога минимально (id/имя/kind + флаги/хвост каталога). diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs index 3520c6e..2dd7620 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs @@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests [Fact] public async Task EvaluateMessage_MlSpam_NotFit() { - (DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true); + (DiscoveryEvaluator evaluator, _, TestMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true); ml.Predict = new MlPredictResultDto( Take: true, Label: "spam", Scores: new Dictionary(), Hits: 3, Ready: true, Margin: 0.9, Terms: Array.Empty(), Type: null); @@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests [Fact] public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() { - (DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false); + (DiscoveryEvaluator evaluator, _, TestMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false); ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает")); DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( @@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests => new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null); // Создаёт оценщик на фейках: флаги веток выставляются сценарием. - private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled) + private static (DiscoveryEvaluator Evaluator, TestSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled) { - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); - var ml = new FakeMlClient(); + var ml = new TestMlClient(); var ai = TestAiTools.New(); - return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai); + return (new DiscoveryEvaluator(settings.Store, ml.Client, ai), settings, ml, ai); } // Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold). diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index 698a23a..fea2453 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests // Контекст теста: воркер на фейках + ссылки для проверок состояния. private sealed record Fixture( TestDiscoveryStore Store, - FakeSettingsStore Settings, + TestSettingsStore Settings, TestDiscoveryGateway Gateway, IDiscoveryPacer Pacer, DiscoveryWorkerService Worker, @@ -429,21 +429,21 @@ public sealed class DiscoveryWorkerServiceTests private static Fixture Create() { var store = new TestDiscoveryStore(); - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false"); - var ml = new FakeMlClient(); + var ml = new TestMlClient(); var ai = TestAiTools.New(); var log = new DiscoveryLogService(store.Store); var blacklist = new DiscoveryBlacklistService(store.Store); var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist); - var planGuard = new DiscoveryPlanGuard(store.Store, settings); - var tasks = new DiscoveryTasksService(store.Store, planGuard, settings); - var banGuard = new DiscoveryBanGuard(store.Store, settings); + var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store); + var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store); + var banGuard = new DiscoveryBanGuard(store.Store, settings.Store); var pacer = Substitute.For(); var gateway = new TestDiscoveryGateway(); var worker = new DiscoveryWorkerService( - store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway); + store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway); return new Fixture(store, settings, gateway, pacer, worker, tasks); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs deleted file mode 100644 index 8824bee..0000000 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-memory реализация для unit-тестов файлов карточки -/// -public sealed class FakeFileStorage : IFileStorage -{ - private readonly Dictionary _objects = new(StringComparer.Ordinal); - private readonly Dictionary _contentTypes = new(StringComparer.Ordinal); - private readonly List _deletedKeys = []; - - /// - /// objectKey всех объектов, сохранённых на данный момент - /// - public IReadOnlyList StoredObjectKeys => _objects.Keys.ToList(); - - /// - /// objectKey всех удалений в порядке вызовов DeleteAsync - /// - public IReadOnlyList DeletedKeys => _deletedKeys.ToList(); - - /// - /// Содержимое сохранённого объекта по ключу либо null — объекта нет - /// - /// Ключ объекта (opaque). - public byte[]? ContentOf(string objectKey) - { - return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null; - } - - /// - public Task 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); - } - - /// - public Task GetAsync(string objectKey, CancellationToken ct) - { - if (!_objects.TryGetValue(objectKey, out byte[]? content)) - { - return Task.FromResult(null); - } - - return Task.FromResult(new MemoryStream(content)); - } - - /// - public Task StatAsync(string objectKey, CancellationToken ct) - { - if (!_objects.TryGetValue(objectKey, out byte[]? content)) - { - return Task.FromResult(null); - } - - string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty; - return Task.FromResult(new FileMeta(objectKey, content.Length, contentType)); - } - - /// - public Task DeleteAsync(string objectKey, CancellationToken ct) - { - _deletedKeys.Add(objectKey); - _objects.Remove(objectKey); - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index 5a637a8..437011a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -39,9 +39,9 @@ public sealed class GrpcAiToolsTests reply.Keywords.Add("python разработка"); reply.Keywords.Add("telegram каналы"); service.GenerateKeywordsReply = reply; - FakeSettingsStore settings = new(); + TestSettingsStore settings = new(); ISecretCipher cipher = TestCiphers.New(); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiTools tools = CreateTools(port, settings, cipher, limits); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); @@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.KeywordsUnavailable = true; - IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); + IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New()); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); @@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests Reason = "другая сфера", Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, }; - FakeTenantLimitStore limits = new(); - IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); + TestTenantLimitStore limits = new(); + IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits); AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); @@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.FitUnavailable = true; - IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); + IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New()); await Assert.ThrowsAsync( () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); @@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests // Возвращает: Экземпляр GrpcAiTools. private static GrpcAiTools CreateTools( int port, - FakeSettingsStore settings, + TestSettingsStore settings, ISecretCipher cipher, - FakeTenantLimitStore? limits = null) + TestTenantLimitStore? limits = null) { + limits ??= new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); return new GrpcAiTools( tenantContext, connection, - new AiProviderConfigBuilder(settings, cipher), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new AiProviderConfigBuilder(settings.Store, cipher), + new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs index 5a43988..484de59 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -110,10 +110,10 @@ public sealed class GrpcMlClientTests Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, }; - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); settings.Preload(SettingsKeys.MlDecisions, "7"); settings.Preload(SettingsKeys.AiDecisions, "3"); - var learning = new FakeMlLearningStore { LearningCount = 9 }; + var learning = new TestMlLearningStore { LearningCount = 9 }; IMlClient client = CreateClient(port, settings, learning); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); @@ -195,7 +195,7 @@ public sealed class GrpcMlClientTests { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { - var learning = new FakeMlLearningStore(); + var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0); learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); IMlClient client = CreateClient(port, learning: learning); @@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests Assert.True(reset.Ok); Assert.Null(reset.Error); - Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена + Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена Assert.Equal(1, service.ResetCalls); _ = await client.StatusAsync(CancellationToken.None); @@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" }; - var learning = new FakeMlLearningStore(); + var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); IMlClient client = CreateClient(port, learning: learning); @@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests Assert.False(reset.Ok); Assert.Equal("не удалось пересоздать файл модели", reset.Error); - Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None)); }); } @@ -238,7 +238,7 @@ public sealed class GrpcMlClientTests await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.ResetUnavailable = true; - var learning = new FakeMlLearningStore(); + var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст", "spam", 1.0); IMlClient client = CreateClient(port, learning: learning); @@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests Assert.False(reset.Ok); Assert.Equal("ML-сервис недоступен", reset.Error); - Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None)); }); } @@ -256,7 +256,7 @@ public sealed class GrpcMlClientTests { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { - var learning = new FakeMlLearningStore(); + var learning = new TestMlLearningStore(); IMlClient client = CreateClient(port, learning: learning); await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); @@ -309,22 +309,24 @@ public sealed class GrpcMlClientTests // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. private static GrpcMlClient CreateClient( int port, - FakeSettingsStore? settings = null, - FakeMlLearningStore? learning = null, + TestSettingsStore? settings = null, + TestMlLearningStore? learning = null, MlStatusCache? cache = null) { + settings ??= new TestSettingsStore(); + learning ??= new TestMlLearningStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; return new GrpcMlClient( tenantContext, - settings ?? new FakeSettingsStore(), - learning ?? new FakeMlLearningStore(), + settings.Store, + learning.Store, new MlGrpcConnection(options), cache ?? new MlStatusCache(), new TokenUsageRecorder( - settings ?? new FakeSettingsStore(), - new FakeTenantLimitStore(), + settings.Store, + new TestTenantLimitStore().Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index ad859a0..e30468d 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; @@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); services.AddSingleton(tenantContext); - services.AddScoped(_ => new FakeSettingsStore()); + services.AddScoped(_ => new TestSettingsStore().Store); services.AddScoped(_ => TestCiphers.New()); - services.AddScoped(_ => new FakeKanjStore()); - services.AddScoped(_ => new FakeMlLearningStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(_ => new TestKanjStore().Store); + services.AddScoped(_ => new TestMlLearningStore().Store); + services.AddScoped(_ => new TestTenantLimitStore().Store); services.AddScoped(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs index 8568af3..7d1e58e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs @@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Contracts; @@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests { // Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер. // Возвращает: Кортеж (KV-настройки, классификатор). - private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() + private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() { - var settings = new FakeSettingsStore(); - var classifier = new LocalAiClassifier(new LocalFieldsParser(settings)); + var settings = new TestSettingsStore(); + var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store)); return (settings, classifier); } @@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests [Fact] public async Task Classify_CustomHireMarkersOverride_AreUsed() { - (FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); + (TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); // Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер. settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]"); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index e3b5a4e..cc4d3a5 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Support; @@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests [Fact] public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim() { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + (PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create(); const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8) + await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8) AiParsedCardDto parsed = Parsed( title: "Python-разработчик на бота", budget: new AiBudgetDto(2000, 2000, "USD"), @@ -38,16 +37,16 @@ public sealed class PipelineCardWriterTests Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget); Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); - Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); + Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); } [Fact] public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn() { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + (PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create(); store.SeedBoard(new ContainerDto { Id = "b_py" }); const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); + await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); CardDto card = await writer.CreateCardAsync( Parsed(title: "Python-разработчик", board: "b_py"), @@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests [Fact] public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup() { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + (PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create(); store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта) const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); + await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); await Assert.ThrowsAsync( () => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None)); @@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests // Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null). Assert.Empty(store.CardDtos); Assert.Null(pipelineStore.DedupLeadId(hash)); - Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте + Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); // заявка на месте } // ─── Хелперы ────────────────────────────────────────────────────────────────────────── - // Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них. + // Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них. // Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна). - private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create() + private static (PipelineCardWriter Writer, TestKanjStore Store, TestPipelineStore PipelineStore) Create() { - var store = new FakeKanjStore(); - var pipelineStore = new FakePipelineStore(); - var composer = new CardComposer(store, new FakeSettingsStore()); - return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore); + var store = new TestKanjStore(); + var pipelineStore = new TestPipelineStore(); + var composer = new CardComposer(store.Store, new TestSettingsStore().Store); + return (new PipelineCardWriter(store.Store, pipelineStore.Store, composer), store, pipelineStore); } // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 1f34802..4cea455 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -15,7 +15,6 @@ using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; @@ -34,7 +33,7 @@ public sealed class PipelineWorkerGrpcAiTests // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; - // Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). + // Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage). private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); [Fact] @@ -127,7 +126,7 @@ public sealed class PipelineWorkerGrpcAiTests Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, }; - var limits = new FakeTenantLimitStore(); + var limits = new TestTenantLimitStore(); limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000); Context ctx = CreateContext(port, limits, budgeted: true); ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); @@ -154,49 +153,49 @@ public sealed class PipelineWorkerGrpcAiTests private sealed record Context( PipelineWorkerService Worker, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeTenantLimitStore Limits); + TestPipelineStore PipelineStore, + TestKanjStore KanjStore, + TestSettingsStore Settings, + TestTenantLimitStore Limits); private static Context CreateContext( int port, - FakeTenantLimitStore? limits = null, + TestTenantLimitStore? limits = null, bool budgeted = false) { - var settings = new FakeSettingsStore(); - var pipelineStore = new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); + var settings = new TestSettingsStore(); + var pipelineStore = new TestPipelineStore(); + var kanjStore = new TestKanjStore(); + var mlClient = new TestMlClient { Predict = NotReadyPrediction() }; + var rules = new IncomingRules(settings.Store); + var fieldsParser = new LocalFieldsParser(settings.Store); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection( new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); - limits ??= new FakeTenantLimitStore(); + limits ??= new TestTenantLimitStore(); var grpcClassifier = new GrpcAiClassifier( tenantContext, connection, - new AiProviderConfigBuilder(settings, TestCiphers.New()), - new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new AiProviderConfigBuilder(settings.Store, TestCiphers.New()), + new AiClassifyContextBuilder(settings.Store, kanjStore.Store), + new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); IAiClassifier aiClassifier = budgeted ? new BudgetedAiClassifier( grpcClassifier, new LocalAiClassifier(fieldsParser), - limits, + limits.Store, tenantContext, NullLogger.Instance) : grpcClassifier; - var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore)); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer); + var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store)); + var composer = new CardComposer(kanjStore.Store, settings.Store); + var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer); var worker = new PipelineWorkerService( - pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); + pipelineStore.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier, processing, writer, fieldsParser); return new Context(worker, pipelineStore, kanjStore, settings, limits); } diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index a44505c..8bcd7bd 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Tests.Unit.Support; using Deal.Modules.Kanban.Application.Services; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Infrastructure; @@ -21,7 +20,7 @@ public sealed class CardMoverTests [Fact] public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() { - (CardMover mover, FakeKanjStore store) = Create(); + (CardMover mover, TestKanjStore store) = Create(); store.SeedCard(new CardDto { Id = "c_1", @@ -44,7 +43,7 @@ public sealed class CardMoverTests [Fact] public async Task Move_ToBoard_RoutesToDashboard() { - (CardMover mover, FakeKanjStore store) = Create(); + (CardMover mover, TestKanjStore store) = Create(); store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } }); @@ -59,7 +58,7 @@ public sealed class CardMoverTests [Fact] public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() { - (CardMover mover, FakeKanjStore store) = Create(); + (CardMover mover, TestKanjStore store) = Create(); store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } }); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); @@ -79,10 +78,10 @@ public sealed class CardMoverTests Assert.False(result.Exists); } - private static (CardMover Mover, FakeKanjStore Store) Create() + private static (CardMover Mover, TestKanjStore Store) Create() { - var store = new FakeKanjStore(); - var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage); + var store = new TestKanjStore(); + var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage); return (new CardMover(cardsService), store); } } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs index 75547bc..abf53b9 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs @@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. TestDiscoveryStore yesterdayStore = new(() => Now); - var settings = new FakeSettingsStore(); - var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now); + var settings = new TestSettingsStore(); + var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now); for (int i = 0; i < 49; i++) { yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); @@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests [Fact] public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay() { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create(); await guard.NoteFloodAsync(CancellationToken.None); @@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests [Fact] public async Task FloodYesterday_DoesNotBlockToday() { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString()); Assert.False(await guard.FloodTodayAsync(CancellationToken.None)); @@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests [Fact] public async Task GlobalPaused_BlocksAutoJoins() { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.DiscPaused, "true"); Assert.True(await guard.GlobalPausedAsync(CancellationToken.None)); @@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests [Fact] public async Task CustomJoinLimit_AppliedWhenSettingStored() { - (DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create(); + (DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.DiscJoinLimit, "2"); Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); @@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests } // Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). - private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create() + private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, TestSettingsStore Settings) Create() { var store = new TestDiscoveryStore(() => Now); - var settings = new FakeSettingsStore(); - var guard = new DiscoveryBanGuard(store.Store, settings, () => Now); + var settings = new TestSettingsStore(); + var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now); return (guard, store, settings); } } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs index f4232ef..d00bd44 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs @@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests [Fact] public async Task Create_CustomThresholdAndSample_Respected() { - (DiscoveryTasksService service, TestDiscoveryStore store, FakeSettingsStore settings) = Create(); + (DiscoveryTasksService service, TestDiscoveryStore store, TestSettingsStore settings) = Create(); settings.Preload("discEvalThreshold", "60"); settings.Preload("discEvalSample", "20"); @@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests } // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). - private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create() + private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create() { var store = new TestDiscoveryStore(); - var settings = new FakeSettingsStore(); - var planGuard = new DiscoveryPlanGuard(store.Store, settings); - return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings); + var settings = new TestSettingsStore(); + var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store); + return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings); } // Draft задачи с дефолтными полями (name/planJoins переопределяются). diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs deleted file mode 100644 index f5662ef..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs +++ /dev/null @@ -1,614 +0,0 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Tests.Unit.Modules.Discovery; - -/// -/// In-memory реализация для unit-тестов Discovery. -/// -public sealed class FakeDiscoveryStore : IDiscoveryStore -{ - private readonly List _tasks = []; - private readonly List _candidates = []; - private readonly List _blacklist = []; - private readonly List _log = []; - private readonly HashSet _monitoredIds = new(StringComparer.Ordinal); - private readonly Func _clock; - - /// - /// Создаёт фейк с «часами» по умолчанию - /// - /// Источник текущего времени (по умолчанию ). - public FakeDiscoveryStore(Func? clock = null) - { - _clock = clock ?? (() => DateTimeOffset.UtcNow); - } - - /// - /// Задачи фейка - /// - public IReadOnlyList Tasks => _tasks.ToList(); - - /// - /// Кандидаты фейка - /// - public IReadOnlyList Candidates => _candidates.ToList(); - - /// - /// Чёрный список фейка - /// - public IReadOnlyList Blacklist => _blacklist.ToList(); - - /// - /// Лог фейка в порядке записи - /// - public IReadOnlyList Log => _log.ToList(); - - /// - /// Кладёт задачу напрямую - /// - /// Задача как если бы была прочитана адаптером. - public void SeedTask(DiscoveryTaskDto task) - { - _tasks.Add(task); - } - - /// - /// Кладёт кандидата напрямую - /// - /// Кандидат как если бы был прочитан адаптером. - public void SeedCandidate(DiscoveryCandidateDto candidate) - { - _candidates.Add(candidate); - } - - /// - /// Помечает источник «уже мониторится» — есть в каталоге Dialogs. - /// - /// Подписанный id источника. - public void SeedMonitored(string dialogId) - { - _monitoredIds.Add(dialogId); - } - - /// - /// Кладёт событие лога напрямую - /// - /// Событие (join_auto/…). - /// Момент события (UTC). - /// Id задачи (по умолчанию — произвольный). - public void SeedLog( - string @event, - DateTimeOffset createdAt, - string taskId = "dt_x") - { - _log.Add(new DiscoveryLogDto("dl_x", taskId, @event, string.Empty, createdAt.ToUnixTimeMilliseconds())); - } - - /// - public Task> ListTasksAsync(CancellationToken ct) - { - return Task.FromResult>(_tasks - .OrderBy(task => task.CreatedAt) - .ToList()); - } - - /// - public Task GetTaskAsync(string taskId, CancellationToken ct) - { - return Task.FromResult(_tasks.FirstOrDefault(task => task.Id == taskId)); - } - - /// - 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; - } - - /// - public Task 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); - } - - /// - public async Task 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; - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) - { - IEnumerable 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)); - } - - /// - public Task> ListCandidatesAsync( - string taskId, - string? status, - CancellationToken ct) - { - IEnumerable query = _candidates.Where(candidate => candidate.TaskId == taskId); - if (status is not null) - { - query = query.Where(candidate => candidate.Status == status); - } - - return Task.FromResult>(query - .OrderBy(candidate => candidate.CreatedAt) - .ToList()); - } - - /// - public Task GetCandidateAsync(string dialogId, CancellationToken ct) - { - return Task.FromResult(_candidates.FirstOrDefault(candidate => candidate.DialogId == dialogId)); - } - - /// - public Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct) - { - return Task.FromResult(_monitoredIds.Contains(dialogId)); - } - - /// - public Task IsBlacklistedAsync(string dialogId, CancellationToken ct) - { - return Task.FromResult(_blacklist.Any(entry => entry.DialogId == dialogId)); - } - - /// - 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(), - new List(), - FitRatio: null, - DiscoveryCandidateStatuses.New, - AutoJoined: false, - JoinFailures: 0, - nowMs, - nowMs)); - return Task.CompletedTask; - } - - /// - public Task DeleteCandidateAsync(string dialogId, CancellationToken ct) - { - _candidates.RemoveAll(candidate => candidate.DialogId == dialogId); - return Task.CompletedTask; - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task 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); - } - - /// - public Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) - { - int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId); - if (index < 0 || _candidates[index].Status != DiscoveryCandidateStatuses.Review) - { - return Task.FromResult(null); - } - - DiscoveryCandidateDto candidate = _candidates[index] with - { - JoinFailures = _candidates[index].JoinFailures + 1, - UpdatedAt = _clock().ToUnixTimeMilliseconds(), - }; - _candidates[index] = candidate; - return Task.FromResult(candidate.JoinFailures); - } - - /// - public Task 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); - } - - /// - 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; - } - - /// - public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) - { - _blacklist.RemoveAll(entry => entry.DialogId == dialogId); - return Task.CompletedTask; - } - - /// - public Task GetBlacklistAsync(string dialogId, CancellationToken ct) - { - return Task.FromResult(_blacklist.FirstOrDefault(entry => entry.DialogId == dialogId)); - } - - /// - public Task> ListBlacklistAsync(CancellationToken ct) - { - return Task.FromResult>(_blacklist - .OrderByDescending(entry => entry.CreatedAt) - .ToList()); - } - - /// - 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; - } - - /// - public Task CountLogEventAsync( - string logEvent, - DateTimeOffset sinceUtc, - CancellationToken ct) - { - long sinceMs = sinceUtc.ToUnixTimeMilliseconds(); - return Task.FromResult(_log.Count(entry => entry.Event == logEvent && entry.CreatedAt >= sinceMs)); - } - - /// - public Task> ListTaskLogAsync( - string taskId, - int limit, - CancellationToken ct) - { - IReadOnlyList entries = _log - .Where(entry => entry.TaskId == taskId) - .OrderByDescending(entry => entry.CreatedAt) - .Take(limit) - .ToList(); - return Task.FromResult(entries); - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs index 0e0628e..829deca 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs @@ -2,8 +2,8 @@ using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Kanban; @@ -24,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildFilterPrompt_FillsDomainAndKeywords() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}.")); settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); - var builder = new AiClassifyContextBuilder(settings, kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); @@ -38,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}")); - var builder = new AiClassifyContextBuilder(settings, kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); @@ -52,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}.")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}.")); settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); - var builder = new AiClassifyContextBuilder(settings, kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None); @@ -69,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - var builder = new AiClassifyContextBuilder(settings, kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None); @@ -82,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); kanj.SeedBoard(new ContainerDto { Id = "b_py", @@ -113,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests Order = 1, }); kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 }); - var builder = new AiClassifyContextBuilder(settings, kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None); @@ -128,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)"); SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)"); - var builder = new AiClassifyContextBuilder(settings, kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None); @@ -144,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests [Fact] public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints() { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - var builder = new AiClassifyContextBuilder(settings, kanj); + TestSettingsStore settings = Context(out TestKanjStore kanj); + var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store); string text = new string('д', 6000); string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None); @@ -158,10 +158,10 @@ public sealed class AiClassifyContextBuilderTests // Создаёт контекст: пустое KV + пустой канбан-фейк. // kanj: Канбан-фейк (доски/журнал примеров). // Возвращает: KV-хранилище тенанта. - private static FakeSettingsStore Context(out FakeKanjStore kanj) + private static TestSettingsStore Context(out TestKanjStore kanj) { - kanj = new FakeKanjStore(); - return new FakeSettingsStore(); + kanj = new TestKanjStore(); + return new TestSettingsStore(); } // Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера). @@ -170,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests // board: Колонка-назначение (доска). // text: Исходный текст карточки (source_msg). private static void SeedCardWithMove( - FakeKanjStore kanj, + TestKanjStore kanj, string cardId, string board, string text) { kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } }); - kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); + kanj.Store.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); } } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs index af636eb..ea7bd08 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs @@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Kanban; @@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот")); CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); @@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Set_Enabled_SetsReminderAndReturnsCard() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1)); long atMs = NowMs() + DayMs; @@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Set_StageNotHold_Allowed() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1", stage: "work", title: "В работе")); long atMs = NowMs() + DayMs; @@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Clear_WithReminder_ClearsItAndReturnsTrue() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); @@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Clear_RemindersDisabled_StillClears() { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); @@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Snooze_MovesReminderToNowPlus24h() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); long beforeMs = NowMs(); @@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task Snooze_RemindersDisabled_StillSnoozes() { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); @@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); long pastMs = NowMs() - 1; store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); @@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests [Fact] public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) }); store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) }); store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); @@ -181,21 +180,21 @@ public sealed class CardsServiceRemindersTests due.Select(item => (item.Id, item.Title))); Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); // «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + Assert.Empty(await store.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); } // ─── Хелперы ────────────────────────────────────────────────────────── - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) + private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true) { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); if (!remindersEnabled) { settings.Preload(SettingsKeys.RemindersEnabled, "false"); } - return (new CardsService(store, settings, new FakeMlClient(), new TestFileStorage().Storage), store, settings); + return (new CardsService(store.Store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings); } // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs deleted file mode 100644 index 43fc9f0..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// In-memory реализация для unit-тестов ML-интеграции. -/// -public sealed class FakeMlLearningStore : IMlLearningStore -{ - private readonly List<(string Id, string Text, string Label, double Delta)> _rows = []; - - /// - /// Число записей журнала обучения - /// - public int LearningCount { get; set; } - - /// - /// Строки очереди - /// - public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList(); - - /// - public Task CountLearningAsync(CancellationToken ct) => Task.FromResult(LearningCount); - - /// - public Task CountOutboxAsync(CancellationToken ct) => Task.FromResult(_rows.Count); - - /// - public Task AddOutboxAsync( - string id, - string text, - string label, - double delta, - CancellationToken ct) - { - _rows.Add((id, text, label, delta)); - return Task.CompletedTask; - } - - /// - /// Пишет строку очереди напрямую - /// - /// Id строки (mle_...). - /// Текст обучающего примера. - /// Метка обучения. - /// Вес сигнала. - public void SeedOutbox( - string id, - string text, - string label, - double delta = 1.0) - { - _rows.Add((id, text, label, delta)); - } - - /// - public Task ClearOutboxAsync(CancellationToken ct) - { - _rows.Clear(); - return Task.CompletedTask; - } - - /// - public Task> TakeOutboxBatchAsync(int limit, CancellationToken ct) - { - return Task.FromResult>( - _rows.Take(limit) - .Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta)) - .ToList()); - } - - /// - public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct) - { - _rows.RemoveAll(row => ids.Contains(row.Id)); - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs deleted file mode 100644 index 742b6f3..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs +++ /dev/null @@ -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; - -/// -/// In-memory реализация для unit-тестов PipelineIngestService/PipelineProcessingService. -/// -public class FakePipelineStore : IPipelineStore -{ - private readonly List _queue = []; - private readonly List _rejected = []; - private readonly Dictionary _dedup = new(StringComparer.Ordinal); - - /// - /// Строки очереди фейка - /// - public IReadOnlyList Queue => _queue.ToList(); - - /// - /// Записи отсева фейка - /// - public IReadOnlyList Rejected => _rejected.ToList(); - - /// - /// Кладёт строку очереди напрямую - /// - /// Строка как если бы была сохранена в БД. - public void SeedQueue(QueueItemDto item) - { - _queue.Add(item); - } - - /// - /// Кладёт запись отсева напрямую - /// - /// Запись как если бы была сохранена в БД. - public void SeedRejected(RejectedItemDto item) - { - _rejected.Add(item); - } - - // ── Очередь (QueueItems) ─────────────────────────────────────────────── - - /// - public Task ExistsDuplicateAsync( - SourceRef source, - CancellationToken ct) - { - string key = source.DedupeKey(); - bool found = _queue.Any(item => item.Source.DedupeKey() == key); - return Task.FromResult(found); - } - - /// - public Task AddAsync(QueueItemDto item, CancellationToken ct) - { - _queue.Add(item); - return Task.CompletedTask; - } - - /// - public virtual Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - IReadOnlyList items = _queue - .Where(item => status is null || item.Status == status) - .OrderBy(item => item.QueuedAtMs) - .Take(limit) - .ToList(); - return Task.FromResult(items); - } - - /// - public Task CountByStatusAsync(string status, CancellationToken ct) - { - return Task.FromResult(_queue.Count(item => item.Status == status)); - } - - /// - 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; - } - - /// - public Task RemoveAsync(string id, CancellationToken ct) - { - _queue.RemoveAll(item => item.Id == id); - return Task.CompletedTask; - } - - // ── Отсев (RejectedItems) ────────────────────────────────────────────── - - /// - 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; - } - - /// - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - IReadOnlyList page = _rejected - .OrderByDescending(item => item.RejectedAtMs) - .Skip(offset) - .Take(limit) - .ToList(); - return Task.FromResult(page); - } - - /// - public Task> SearchAsync( - string q, - int limitFts, - int limitLike, - CancellationToken ct) - { - string query = q.Trim(); - IReadOnlyList 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); - } - - /// - public Task CountAsync(CancellationToken ct) - { - return Task.FromResult(_rejected.Count); - } - - /// - public Task GetAsync(string id, CancellationToken ct) - { - return Task.FromResult(_rejected.FirstOrDefault(item => item.Id == id)); - } - - /// - public Task DeleteAsync(string id, CancellationToken ct) - { - _rejected.RemoveAll(item => item.Id == id); - return Task.CompletedTask; - } - - /// - public Task ClearAsync(CancellationToken ct) - { - int cleared = _rejected.Count; - _rejected.Clear(); - return Task.FromResult(cleared); - } - - /// - public Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) - { - long cutMs = olderThan.ToUnixTimeMilliseconds(); - int removed = _rejected.RemoveAll(item => item.RejectedAtMs < cutMs); - return Task.FromResult(removed); - } - - /// - 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) ─────────────────────────────────────────────── - - /// - /// Id карточки, связанной с хэшем дедупа - /// - /// SHA1-hex нормализованного текста. - public string? DedupLeadId(string hash) - { - return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null; - } - - /// - public virtual Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(_dedup.ContainsKey(hash)); - } - - /// - public virtual Task ClaimAsync(string hash, CancellationToken ct) - { - // Атомарность claim'а (как PipelineStore.ClaimAsync, ON CONFLICT DO NOTHING): true — заявка занята - // этим вызовом, false — хэш уже заявлен (TryAdd) другим проходом pump. - return Task.FromResult(_dedup.TryAdd(hash, null)); - } - - /// - public virtual Task DeleteClaimAsync(string hash, CancellationToken ct) - { - if (_dedup.TryGetValue(hash, out string? leadId) && leadId is null) - { - _dedup.Remove(hash); - } - - return Task.CompletedTask; - } - - /// - public Task LinkAsync( - string hash, - string cardId, - CancellationToken ct) - { - _dedup[hash] = cardId; - return Task.CompletedTask; - } - - /// - 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, - }; -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs index 8d12a2c..9a2e827 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -5,7 +5,6 @@ using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Support; @@ -19,15 +18,15 @@ public sealed class MlReviewServiceTests private const string Dialog = "d_1"; private static MlReviewService Create( - FakePipelineStore pipeline, - FakeKanjStore kanj, - FakeMlClient ml, + TestPipelineStore pipeline, + TestKanjStore kanj, + TestMlClient ml, out CardsService cards) { - var settings = new FakeSettingsStore(); - cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage); - var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); - return new MlReviewService(pipeline, kanj, cards, processing, ml); + var settings = new TestSettingsStore(); + cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage); + var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store)); + return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client); } private static QueueItemDto QueueRow( @@ -73,12 +72,12 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "из очереди")); pipeline.SeedRejected(RejectedRow(102, "из отсева")); - var kanj = new FakeKanjStore(); + var kanj = new TestKanjStore(); kanj.SeedCard(Card(103, "из карточки")); - MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _); + MlReviewService service = Create(pipeline, kanj, new TestMlClient(), out _); IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); @@ -94,10 +93,10 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_FiltersByDialog() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _); IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); @@ -108,10 +107,10 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_NoDialog_ReturnsAllSources() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "a", "d_1")); pipeline.SeedQueue(QueueRow(201, "b", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _); IReadOnlyList items = await service.CandidatesAsync(null, 10, CancellationToken.None); @@ -121,13 +120,13 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_ClampsLimitToMax() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); for (int i = 0; i < 5; i++) { pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); } - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _); IReadOnlyList items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None); @@ -137,10 +136,10 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Skip_DoesNotLearn() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + var ml = new TestMlClient(); + MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); @@ -154,10 +153,10 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Spam_WithCard_TrashesAndLearns() { - var kanj = new FakeKanjStore(); + var kanj = new TestKanjStore(); kanj.SeedCard(Card(101, "спамный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + var ml = new TestMlClient(); + MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); @@ -172,10 +171,10 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "рекламный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + var ml = new TestMlClient(); + MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); @@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Board_MovesCardAndLearns() { - var kanj = new FakeKanjStore(); + var kanj = new TestKanjStore(); kanj.SeedBoard(new ContainerDto { Id = "b_py", @@ -201,8 +200,8 @@ public sealed class MlReviewServiceTests Kind = ContainerKinds.Board, }); kanj.SeedCard(Card(101, "python разработчик")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + var ml = new TestMlClient(); + MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); @@ -216,9 +215,9 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_UnknownBoard_ReturnsError() { - var kanj = new FakeKanjStore(); + var kanj = new TestKanjStore(); kanj.SeedCard(Card(101, "текст")); - MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); + MlReviewService service = Create(new TestPipelineStore(), kanj, new TestMlClient(), out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); @@ -230,9 +229,9 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_UnknownAction_ReturnsError() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "текст")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None); @@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_MessageNotFound_ReturnsNull() { - MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs index 47ef4a8..69fd13d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs @@ -1,7 +1,7 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Kanban; @@ -15,7 +15,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards() { - (StorageTickService service, FakeKanjStore store, _) = Create(); + (StorageTickService service, TestKanjStore store, _) = Create(); store.SeedBoard(Board("b_1")); store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16)))); store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат @@ -47,7 +47,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace() { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.AutoArchive, "false"); store.SeedBoard(Board("b_1")); store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30)))); @@ -62,7 +62,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth() { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.ArchiveAfterDays, "30"); store.SeedBoard(Board("b_1")); store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32)))); @@ -78,7 +78,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard() { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.ArchiveAfterDays, "1"); store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2)))); @@ -91,7 +91,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards() { - (StorageTickService service, FakeKanjStore store, _) = Create(); + (StorageTickService service, TestKanjStore store, _) = Create(); store.SeedBoard(Board("b_1")); store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20)))); @@ -109,7 +109,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays() { - (StorageTickService service, FakeKanjStore store, _) = Create(); + (StorageTickService service, TestKanjStore store, _) = Create(); DateTimeOffset now = DateTimeOffset.UtcNow; store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн. @@ -129,7 +129,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary() { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.ArchiveClearDays, "30"); DateTimeOffset now = DateTimeOffset.UtcNow; store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); @@ -148,7 +148,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays() { - (StorageTickService service, FakeKanjStore store, _) = Create(); + (StorageTickService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн. store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6)))); @@ -165,7 +165,7 @@ public sealed class StorageTickServiceTests [Fact] public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary() { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.TrashClearDays, "3"); store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн. store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1)))); @@ -194,11 +194,11 @@ public sealed class StorageTickServiceTests // ─── Хелперы ──────────────────────────────────────────────────────────── - private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() + private static (StorageTickService Service, TestKanjStore Store, TestSettingsStore Settings) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new StorageTickService(store, settings), store, settings); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + return (new StorageTickService(store.Store, settings.Store), store, settings); } // Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка). @@ -215,13 +215,13 @@ public sealed class StorageTickServiceTests private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); // Колонка карточки в фейке после тика. - private static string ColOf(FakeKanjStore store, string cardId) => + private static string ColOf(TestKanjStore store, string cardId) => store.CardDtos.Single(card => card.Id == cardId).Col; // Флаг is_new карточки после тика. - private static bool IsNewOf(FakeKanjStore store, string cardId) => + private static bool IsNewOf(TestKanjStore store, string cardId) => store.CardDtos.Single(card => card.Id == cardId).IsNew; - private static IReadOnlyList MatchHitsOf(FakeKanjStore store, string cardId) => + private static IReadOnlyList MatchHitsOf(TestKanjStore store, string cardId) => store.CardDtos.Single(card => card.Id == cardId).MatchHits; } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs index 3262c9d..fc224d5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -1,7 +1,6 @@ using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Pipeline; @@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); PipelineIngestResultDto result = await service.EnqueueAsync( Message( @@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); await service.EnqueueAsync( Message("Сообщение без времени", receivedAt: default(DateTimeOffset)), @@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None); @@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None); PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None); @@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7"); QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7"); @@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None); PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None); @@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); PipelineIngestResultDto first = await service.EnqueueAsync( Message("Первое", origin: "d_1", externalId: null), CancellationToken.None); @@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests [Fact] public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints() { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); + TestPipelineStore store = new(); + PipelineIngestService service = new(store.Store); string longText = new string('а', 7_000); await service.EnqueueAsync(Message(longText), CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs index a0d8c0a..6ea34d0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -3,7 +3,6 @@ using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Pipeline; @@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task RejectAsync_WhitespaceText_Noop() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None); @@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); await service.RejectAsync(new RejectRecord { @@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None); await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None); @@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task QueueCountsAsync_CountsNewAndFiltered() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2)); store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3)); @@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_2")); @@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30)); store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10)); store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20)); @@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000)); store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000)); @@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000)); store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000)); @@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000)); store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000)); @@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_RecordNotFound_ReturnsNull() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None); @@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_AlreadyReturned_Returns400() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было")); RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None); @@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_DuplicateSource_Returns400() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup")); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); @@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_EmptyText_Returns400() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected("r_1", text: " ")); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); @@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected( "r_1", text: " Продвижение в каналах ", @@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected( "r_1", text: "Короткое сообщение", @@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1")); await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None); @@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue() { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); store.SeedRejected(Rejected( "r_old", text: "Старая запись без ссылки на исходное", @@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task DeleteAsync_RemovesSingleRecord() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_2")); @@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task ClearAsync_ReturnsCountAndEmptiesRejects() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); store.SeedRejected(Rejected("r_1")); store.SeedRejected(Rejected("r_2")); store.SeedRejected(Rejected("r_3")); @@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests [Fact] public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention() { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + (PipelineProcessingService service, TestPipelineStore store, _) = Create(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); @@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests // ─── Помощники ────────────────────────────────────────────────────────── - // Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария. - private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create() + // Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария. + private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create() { - FakePipelineStore store = new(); - FakeMlClient ml = new(); - PipelineProcessingService service = new(store, ml, new PipelineIngestService(store)); + TestPipelineStore store = new(); + TestMlClient ml = new(); + PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store)); return (service, store, ml); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs deleted file mode 100644 index f3fad0e..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Tests.Unit.Modules.Settings; - -/// -/// In-memory реализация для юнит-тестов SettingsService. -/// -public sealed class FakeSettingsStore : ISettingsStore -{ - private readonly Dictionary _rows = new(StringComparer.Ordinal); - - public IReadOnlyCollection Keys => _rows.Keys.ToList(); - - /// - /// Кладёт готовую строку - /// - /// Ключ настройки. - /// Значение, сериализованное в JSON. - public void Preload(string key, string valueJson) - { - _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); - } - - /// - /// Возвращает JSON сохранённого значения или null, если ключа нет. - /// - /// Ключ настройки. - /// JSON-строка значения или null. - public string? GetStoredJson(string key) - { - return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null; - } - - /// - public Task GetAsync(string key, CancellationToken ct) - { - return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null); - } - - /// - public Task> GetAllAsync(CancellationToken ct) - { - return Task.FromResult>(_rows.Values.ToList()); - } - - /// - public Task SetAsync( - string key, - string valueJson, - CancellationToken ct) - { - _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); - return Task.CompletedTask; - } - - /// - public Task RemoveAsync(string key, CancellationToken ct) - { - _rows.Remove(key); - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs index f50aad6..2d01bf0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs @@ -1,6 +1,7 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; @@ -14,8 +15,8 @@ public sealed class AuditServiceTests [Fact] public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields() { - var store = new FakeAuditLogStore(); - var service = new AuditService(store); + var store = new TestAuditLogStore(); + var service = new AuditService(store.Store); var actorId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var before = DateTimeOffset.UtcNow; @@ -43,11 +44,11 @@ public sealed class AuditServiceTests [Fact] public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-2)); await AppendWithAtAsync(store, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1)); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow); - var service = new AuditService(store); + var service = new AuditService(store.Store); var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None); Assert.Equal(3, all.Count); @@ -71,7 +72,7 @@ public sealed class AuditServiceTests [Fact] public async Task QueryAsync_AppliesAtRangeAndTenantFilters() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); var tenantId = Guid.NewGuid(); var outsideTenantId = Guid.NewGuid(); var from = DateTimeOffset.UtcNow.AddMinutes(-10); @@ -79,7 +80,7 @@ public sealed class AuditServiceTests await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from.AddMinutes(-1)); // вне окна await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to); - var service = new AuditService(store); + var service = new AuditService(store.Store); var byRange = await service.QueryAsync( new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None); @@ -94,13 +95,13 @@ public sealed class AuditServiceTests [Fact] public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < 7; i++) { await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow); } - var service = new AuditService(store); + var service = new AuditService(store.Store); var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2); Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None)); @@ -163,13 +164,13 @@ public sealed class AuditServiceTests // Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров). private static async Task AppendWithAtAsync( - FakeAuditLogStore store, + TestAuditLogStore store, string eventType, string actorType, Guid? tenantId, DateTimeOffset at) { - await store.AppendAsync(new AuditRecordDto( + await store.Store.AppendAsync(new AuditRecordDto( eventType, actorType, ActorId: null, diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs index c2b4783..c652716 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs @@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере». +/// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере». /// public sealed class AuthServiceTests { @@ -17,17 +17,17 @@ public sealed class AuthServiceTests private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid UserTenantId = Guid.NewGuid(); - private readonly FakeAuthStore _store; + private readonly TestAuthStore _store; private readonly TestTenantStore _tenantStore; private readonly AuthService _service; public AuthServiceTests() { var passwordHasher = TestHashers.New(); - _store = new FakeAuthStore(); + _store = new TestAuthStore(); _tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); _store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); - _service = new AuthService(_store, passwordHasher, _tenantStore.Repository); + _service = new AuthService(_store.Store, passwordHasher, _tenantStore.Repository); } [Fact] @@ -133,7 +133,7 @@ public sealed class AuthServiceTests var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None); Assert.Null(user); - // Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища. + // Очистка «протухших» сессий вызвана и удалила сессию из хранилища. Assert.Contains("delete-expired", _store.Calls); Assert.Empty(_store.Sessions); } @@ -204,10 +204,10 @@ public sealed class AuthServiceTests public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession() { var passwordHasher = TestHashers.New(); - var suspendedStore = new FakeAuthStore(); + var suspendedStore = new TestAuthStore(); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); - var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository); + var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository); var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None); @@ -225,10 +225,10 @@ public sealed class AuthServiceTests // Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает // приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты). var passwordHasher = TestHashers.New(); - var suspendedStore = new FakeAuthStore(); + var suspendedStore = new TestAuthStore(); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); - var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository); + var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository); var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None); @@ -266,12 +266,12 @@ public sealed class AuthServiceTests public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser() { var passwordHasher = TestHashers.New(); - var multiUserStore = new FakeAuthStore(); + var multiUserStore = new TestAuthStore(); var firstUserId = Guid.NewGuid(); var secondUserId = Guid.NewGuid(); multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1"))); multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2"))); - var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore.Repository); + var multiUserService = new AuthService(multiUserStore.Store, passwordHasher, _tenantStore.Repository); // login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере). var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None); @@ -294,9 +294,9 @@ public sealed class AuthServiceTests // Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина). var passwordHasher = TestHashers.New(); - var crossStore = new FakeAuthStore(); + var crossStore = new TestAuthStore(); crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p"))); - var crossService = new AuthService(crossStore, passwordHasher, _tenantStore.Repository); + var crossService = new AuthService(crossStore.Store, passwordHasher, _tenantStore.Repository); var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None); Assert.False(cross.Ok); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs deleted file mode 100644 index 28ce2be..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs +++ /dev/null @@ -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; - -/// -/// In-memory реализация для юнит/HTTP-тестов аудита. -/// -public sealed class FakeAuditLogStore : IAuditLogStore -{ - private readonly List _records = []; - - /// - /// Записи хранилища в порядке добавления - /// - public IReadOnlyList Records => _records; - - /// - public Task AppendAsync(AuditRecordDto record, CancellationToken ct) - { - _records.Add(record with { Id = _records.Count + 1 }); - return Task.CompletedTask; - } - - /// - public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) - { - IReadOnlyList 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); - } - - /// - public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => - Task.FromResult(ApplyFilters(filter).Count()); - - /// - public Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct) - { - int removed = _records.RemoveAll(record => record.At < cutoff); - return Task.FromResult(removed); - } - - private IEnumerable 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)); -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs deleted file mode 100644 index 518f7b6..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для юнит-тестов AuthService. -/// -public sealed class FakeAuthStore : IAuthStore -{ - private readonly List _users = []; - private readonly List _sessions = []; - private readonly List _calls = []; - - /// - /// Пользователи хранилища. - /// - public IReadOnlyList Users => _users; - - /// - /// Сессии хранилища. - /// - public IReadOnlyList Sessions => _sessions; - - /// - /// Журнал мутирующих вызовов в порядке их совершения. - /// - public IReadOnlyList Calls => _calls; - - /// - /// Добавляет пользователя - /// - /// Пользователь. - public void AddUser(StoredUserDto user) => _users.Add(user); - - /// - /// Добавляет сессию - /// - /// Сессия. - public void AddSession(SessionDto session) => _sessions.Add(session); - - /// - public Task FindUserByLoginAsync(string login, CancellationToken ct) => - Task.FromResult(_users.SingleOrDefault(u => u.Login == login)); - - /// - public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) => - Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash)); - - /// - public Task 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)); - } - - /// - public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) => - Task.FromResult>(_users - .Where(u => u.TenantId == tenantId) - .Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status)) - .ToList()); - - /// - public Task CreateUserAsync(StoredUserDto user, CancellationToken ct) - { - _users.Add(user); - _calls.Add($"create-user:{user.Id}"); - return Task.CompletedTask; - } - - /// - public Task CreateSessionAsync(SessionDto session, CancellationToken ct) - { - _sessions.Add(session); - _calls.Add($"create-session:{session.UserId}"); - return Task.CompletedTask; - } - - /// - public Task DeleteSessionAsync(string tokenHash, CancellationToken ct) - { - _sessions.RemoveAll(s => s.TokenHash == tokenHash); - _calls.Add($"delete-session:{tokenHash}"); - return Task.CompletedTask; - } - - /// - public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct) - { - _sessions.RemoveAll(s => s.UserId == userId); - _calls.Add($"delete-user-sessions:{userId}"); - return Task.CompletedTask; - } - - /// - 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; - } - - /// - public Task DeleteExpiredSessionsAsync(CancellationToken ct) - { - _sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow); - _calls.Add("delete-expired"); - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs deleted file mode 100644 index 91b350d..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для юнит-тестов OperatorAuthService. -/// -public sealed class FakeOperatorAuthStore : IOperatorAuthStore -{ - private readonly List _operators = []; - private readonly List _sessions = []; - private readonly List _calls = []; - - /// - /// Операторы хранилища. - /// - public IReadOnlyList Operators => _operators; - - /// - /// Сессии операторов хранилища. - /// - public IReadOnlyList Sessions => _sessions; - - /// - /// Журнал мутирующих вызовов в порядке их совершения. - /// - public IReadOnlyList Calls => _calls; - - /// - /// Добавляет оператора - /// - /// Оператор. - public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord); - - /// - /// Добавляет сессию - /// - /// Сессия. - public void AddSession(OperatorSessionDto session) => _sessions.Add(session); - - /// - public Task FindByLoginAsync(string login, CancellationToken ct) => - Task.FromResult(_operators.SingleOrDefault(o => o.Login == login)); - - /// - public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) => - Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash)); - - /// - public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct) - { - _operators.Add(operatorRecord); - _calls.Add($"create-operator:{operatorRecord.Id}"); - return Task.CompletedTask; - } - - /// - public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct) - { - _sessions.Add(session); - _calls.Add($"create-session:{session.OperatorId}"); - return Task.CompletedTask; - } - - /// - public Task DeleteSessionAsync(string tokenHash, CancellationToken ct) - { - _sessions.RemoveAll(s => s.TokenHash == tokenHash); - _calls.Add($"delete-session:{tokenHash}"); - return Task.CompletedTask; - } - - /// - public Task DeleteExpiredSessionsAsync(CancellationToken ct) - { - _sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow); - _calls.Add("delete-expired"); - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs deleted file mode 100644 index 86a4192..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для unit-тестов -/// -public sealed class FakeTenantRepository : ITenantRepository -{ - private readonly IReadOnlyList _tenants; - private readonly List<(int Offset, int Limit)> _pageRequests = []; - - /// - /// Создаёт реестр с фиксированным списком тенантов. - /// - /// Тенанты реестра (как строки public.tenants). - public FakeTenantRepository(params TenantRecordDto[] tenants) - { - _tenants = tenants.ToList(); - } - - /// - /// Запрошенные страницы (offset, limit) в порядке обращений. - /// - public IReadOnlyList<(int Offset, int Limit)> PageRequests => _pageRequests; - - /// - public Task> ListAsync(CancellationToken ct) => - Task.FromResult>(_tenants.ToList()); - - /// - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - _pageRequests.Add((offset, limit)); - IReadOnlyList page = _tenants.Skip(offset).Take(limit).ToList(); - return Task.FromResult(page); - } - - /// - public Task FindByIdAsync(Guid id, CancellationToken ct) => - throw new NotSupportedException("FindByIdAsync не используется тестами реестра"); - - /// - public Task UpdateStatusAsync( - Guid id, - string status, - CancellationToken ct) => - throw new NotSupportedException("UpdateStatusAsync не используется тестами реестра"); - - /// - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) => - throw new NotSupportedException("CreateAsync не используется тестами реестра"); -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs deleted file mode 100644 index 24e12b6..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация с поддержкой создания тенантов. -/// -public sealed class FakeTenantStore : ITenantRepository -{ - private readonly List _tenants; - - /// - /// Создаёт хранилище, опционально с предзаполненным реестром тенантов. - /// - /// Тенанты, которые уже есть в реестре (как строки public.tenants). - public FakeTenantStore(params TenantRecordDto[] seed) - { - _tenants = seed.ToList(); - } - - /// - /// Записи реестра в порядке добавления. - /// - public IReadOnlyList Tenants => _tenants; - - /// - public Task FindByIdAsync(Guid id, CancellationToken ct) => - Task.FromResult(_tenants.FirstOrDefault(tenant => tenant.Id == id)); - - /// - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) - { - _tenants.Add(tenant); - return Task.CompletedTask; - } - - /// - public Task> ListAsync(CancellationToken ct) => - Task.FromResult>(_tenants.ToList()); - - /// - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - IReadOnlyList page = _tenants.Skip(offset).Take(limit).ToList(); - return Task.FromResult(page); - } - - /// - public Task 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); - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs deleted file mode 100644 index 6394a0b..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для юнит/HTTP-тестов. -/// -public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore -{ - private readonly List _records = []; - - /// - /// Записи хранилища в порядке добавления. - /// - public IReadOnlyList Records => _records; - - /// - public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) - { - _records.Add(record); - return Task.CompletedTask; - } - - /// - public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) - { - IEnumerable 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 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 GroupByDay(IEnumerable 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 GroupByString(IEnumerable source, Func keySelector) => - source - .GroupBy(keySelector) - .Select(group => ToAggregate(group.Key, group)) - .OrderByDescending(row => row.TotalTokens) - .ToList(); - - private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable group) - { - List rows = group.ToList(); - return new TokenUsageAggregateDto( - key, - rows.Sum(row => row.PromptTokens), - rows.Sum(row => row.CompletionTokens), - rows.Sum(row => row.TotalTokens), - rows.Count); - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs index 2b4bc8e..6ac3d1b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs @@ -29,7 +29,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var provisioner = new TestTenantProvisioner(); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var passwordHasher = TestHashers.New(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher); @@ -70,7 +70,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new TestTenantStore(); - var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None); @@ -87,7 +87,7 @@ public sealed class JoinFlowTests var provisioner = new TestTenantProvisioner(); // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -108,7 +108,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -126,7 +126,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -142,7 +142,7 @@ public sealed class JoinFlowTests { var inviteStore = new TestInviteStore(); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None); @@ -159,7 +159,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -176,7 +176,7 @@ public sealed class JoinFlowTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); - var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -189,7 +189,7 @@ public sealed class JoinFlowTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); - var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -203,7 +203,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None); @@ -221,7 +221,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash")); var provisioner = new TestTenantProvisioner(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); @@ -248,7 +248,7 @@ public sealed class JoinFlowTests var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None); @@ -267,7 +267,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var provisioner = new TestTenantProvisioner(); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -288,7 +288,7 @@ public sealed class JoinFlowTests // Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится. inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var provisioner = new TestTenantProvisioner(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); @@ -311,7 +311,7 @@ public sealed class JoinFlowTests // Конкурентная активация «успела» до нашего CAS — приглашение уже активировано. inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var provisioner = new TestTenantProvisioner(); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); @@ -353,17 +353,17 @@ public sealed class JoinFlowTests // ─── Хелперы ───────────────────────────────────────────────────────── - // Собирает JoinService на общих фейк-хранилищах (все состояния — свежие). + // Собирает JoinService на общих подставках хранилищ (все состояния — свежие). private static JoinService NewService( TestInviteStore inviteStore, TestTenantStore tenantStore, ITenantProvisioner provisioner, - FakeAuthStore authStore, + TestAuthStore authStore, IPasswordHasher passwordHasher) => new( new InvitesService(inviteStore.Store), new TenantService(tenantStore.Repository, provisioner), - authStore, + authStore.Store, passwordHasher, tenantStore.Repository); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs index 4603707..75de423 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs @@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests private static readonly Guid OperatorId = Guid.NewGuid(); - private readonly FakeOperatorAuthStore _store; + private readonly TestOperatorAuthStore _store; private readonly OperatorAuthService _service; public OperatorAuthServiceTests() { var passwordHasher = TestHashers.New(); - _store = new FakeOperatorAuthStore(); + _store = new TestOperatorAuthStore(); _store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); - _service = new OperatorAuthService(_store, passwordHasher); + _service = new OperatorAuthService(_store.Store, passwordHasher); } [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs index c44ed26..02b5a94 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs @@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants; /// public sealed class OperatorBootstrapServiceTests { - private readonly FakeOperatorAuthStore _store; + private readonly TestOperatorAuthStore _store; private readonly IPasswordHasher _passwordHasher; private readonly OperatorBootstrapService _service; public OperatorBootstrapServiceTests() { _passwordHasher = TestHashers.New(); - _store = new FakeOperatorAuthStore(); - _service = new OperatorBootstrapService(_store, _passwordHasher); + _store = new TestOperatorAuthStore(); + _service = new OperatorBootstrapService(_store.Store, _passwordHasher); } [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs index 1e2498b..c54c787 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs @@ -1,5 +1,6 @@ using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; @@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_NoRecords_ReturnsEmpty() { - SuspiciousActivityService service = Create(new FakeAuditLogStore()); + SuspiciousActivityService service = Create(new TestAuditLogStore()); SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); @@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) { SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i); @@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2; for (int i = 0; i < count; i++) { @@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++) { SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i); @@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_ManyIpsPerActor_Triggers() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); Guid actor = Guid.NewGuid(); for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++) { - await store.AppendAsync( + await store.Store.AppendAsync( new AuditRecordDto( AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, @@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++) { - await store.AppendAsync( + await store.Store.AppendAsync( new AuditRecordDto( AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, @@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) { SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i); @@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2; for (int i = 0; i < count; i++) { @@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++) { SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i); @@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++) { SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i); @@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) { SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i); @@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests [Fact] public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() { - var store = new FakeAuditLogStore(); + var store = new TestAuditLogStore(); for (int i = 0; i < 50; i++) { SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток @@ -239,12 +240,12 @@ public sealed class SuspiciousActivityServiceTests // Пишет запись «неудачный вход» с заданным временем. private static void SeedFailed( - FakeAuditLogStore store, + TestAuditLogStore store, string ip, string login, int minutesAgo) { - store.AppendAsync( + store.Store.AppendAsync( new AuditRecordDto( AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, @@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests // Пишет запись «успешный вход» с заданным временем. private static void SeedSuccess( - FakeAuditLogStore store, + TestAuditLogStore store, string ip, string login, int minutesAgo) { - store.AppendAsync( + store.Store.AppendAsync( new AuditRecordDto( AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, @@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests CancellationToken.None); } - private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); + private static SuspiciousActivityService Create(TestAuditLogStore store) => new(store.Store, () => Now); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs index b56cfb3..e0cb343 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs @@ -18,7 +18,7 @@ public sealed class TenantAdminServiceTests { var tenantStore = new TestTenantStore(); var provisioner = new TestTenantProvisioner(); - var service = NewService(tenantStore, new FakeAuthStore(), provisioner); + var service = NewService(tenantStore, new TestAuthStore(), provisioner); TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None); @@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword() { var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var hasher = TestHashers.New(); var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher); @@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant() { var tenantStore = new TestTenantStore(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com")); var service = NewService(tenantStore, authStore); @@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail() { var tenantStore = new TestTenantStore(); - var service = NewService(tenantStore, new FakeAuthStore()); + var service = NewService(tenantStore, new TestAuthStore()); TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None); @@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests public async Task CreateAsync_WithEmptyName_ReturnsNameRequired() { var tenantStore = new TestTenantStore(); - var service = NewService(tenantStore, new FakeAuthStore()); + var service = NewService(tenantStore, new TestAuthStore()); TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None); @@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests var tenantStore = new TestTenantStore( new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); authStore.AddUser(NewUser(FirstTenantId, "a@example.com")); authStore.AddUser(NewUser(FirstTenantId, "b@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); @@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers() { var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); authStore.AddUser(NewUser(FirstTenantId, "a@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); var service = NewService(tenantStore, authStore); @@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests [Fact] public async Task GetAsync_ForUnknownTenant_ReturnsNull() { - var service = NewService(new TestTenantStore(), new FakeAuthStore()); + var service = NewService(new TestTenantStore(), new TestAuthStore()); Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None)); } @@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged() { var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var service = NewService(tenantStore, new FakeAuthStore()); + var service = NewService(tenantStore, new TestAuthStore()); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None); @@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange() { var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); - var service = NewService(tenantStore, new FakeAuthStore()); + var service = NewService(tenantStore, new TestAuthStore()); // Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность). TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None); @@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests [Fact] public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound() { - var service = NewService(new TestTenantStore(), new FakeAuthStore()); + var service = NewService(new TestTenantStore(), new TestAuthStore()); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None); @@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests Assert.Null(result.Tenant); } - // Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку). + // Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку). private static TenantAdminService NewService( TestTenantStore tenantStore, - FakeAuthStore authStore, + TestAuthStore authStore, TestTenantProvisioner? provisioner = null, IPasswordHasher? hasher = null) { var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner); - return new TenantAdminService(tenantStore.Repository, authStore, tenantService, hasher ?? TestHashers.New()); + return new TenantAdminService(tenantStore.Repository, authStore.Store, tenantService, hasher ?? TestHashers.New()); } // Пользователь тенанта (активный, без хэша — для чтения). diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs similarity index 72% rename from src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs rename to src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs index cb1cb2c..ff224e1 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TestTenantLimitStore.cs @@ -1,13 +1,15 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; +using NSubstitute; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит-тестов recorder'а и бюджетного гейта/алертов +/// Подставка для юнит-тестов recorder'а и бюджетного гейта/алертов: +/// сервисы получают NSubstitute-подставку (), тесты управляют сценарием через свойства /// -public sealed class FakeTenantLimitStore : ITenantLimitStore +public sealed class TestTenantLimitStore { private sealed class Row { @@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore private readonly Dictionary _rows = new(); private readonly TokenBudgetService _budgetService = new(); + + /// + /// Подставка порта лимитов (создаётся в конструкторе). + /// + public ITenantLimitStore Store { get; } private readonly Func _utcNow; /// - /// Создаёт фейк с системными часами и статусом тенанта active. + /// Создаёт подставку с системными часами и статусом тенанта active. /// - public FakeTenantLimitStore() + public TestTenantLimitStore() : this(() => DateTimeOffset.UtcNow, TenantStatuses.Active) { } /// - /// Создаёт фейк с заданными часами и статусом тенанта + /// Создаёт подставку с заданными часами и статусом тенанта /// /// Источник текущего времени (UTC). /// Статус тенанта для всех строк (константа ). - public FakeTenantLimitStore(Func utcNow, string tenantStatus = TenantStatuses.Active) + public TestTenantLimitStore(Func utcNow, string tenantStatus = TenantStatuses.Active) { ArgumentNullException.ThrowIfNull(utcNow); _utcNow = utcNow; TenantStatus = tenantStatus; + Store = Substitute.For(); + Store.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => GetOrCreateAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.GetStateAsync(Arg.Any(), Arg.Any()) + .Returns(ci => GetStateAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.AddUsageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => AddUsageAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.UpdateBudgetAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => UpdateBudgetAsync(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3))); + Store.TryMarkWarnedAsync(Arg.Any(), Arg.Any()) + .Returns(ci => TryMarkWarnedAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.TryMarkNotifiedExhaustedAsync(Arg.Any(), Arg.Any()) + .Returns(ci => TryMarkNotifiedExhaustedAsync(ci.ArgAt(0), ci.ArgAt(1))); + Store.ResetExpiredPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ResetExpiredPeriodsAsync(ci.ArgAt(0), ci.ArgAt(1))); } /// @@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore public string TenantStatus { get; set; } /// - /// Тенанты, для которых бросает исключение (устойчивость сборщиков) + /// Тенанты, для которых подставки бросает исключение (устойчивость сборщиков) /// public HashSet FailStateReads { get; } = new(); /// - /// Кладёт готовую строку лимита + /// Кладёт готовую строку лимита (в состояние подставки) /// /// Тенант. /// Бюджет периода. @@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore /// True — строка существует. public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId); - /// - public Task GetOrCreateAsync( + private Task GetOrCreateAsync( Guid tenantId, CancellationToken ct, - TokenLimitDefaults? defaults = null) + TokenLimitDefaults? defaults) { Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default); return Task.FromResult(ToLimitDto(row, tenantId)); } - /// - public Task GetStateAsync(Guid tenantId, CancellationToken ct) + private Task GetStateAsync(Guid tenantId, CancellationToken ct) { if (FailStateReads.Contains(tenantId)) { @@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task AddUsageAsync( + private Task AddUsageAsync( Guid tenantId, long tokens, CancellationToken ct) @@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task UpdateBudgetAsync( + private Task UpdateBudgetAsync( Guid tenantId, long budgetTokens, string period, @@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(ToStateDto(row, tenantId)); } - /// - public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) + private Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) { Row row = Ensure(tenantId, TokenBudgetDefaults.Default); ResetIfPeriodExpired(row); @@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(true); } - /// - public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) + private Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) { Row row = Ensure(tenantId, TokenBudgetDefaults.Default); ResetIfPeriodExpired(row); @@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore return Task.FromResult(true); } - /// - public Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) + private Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) { int reset = 0; foreach (Row row in _rows.Values) diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 05c7bdf..3409bed 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -3,6 +3,7 @@ using Deal.Api.Dtos; using Deal.Api.Events; using Deal.Api.Services; using Deal.Contracts.Integrations.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; @@ -10,8 +11,8 @@ using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; +using NSubstitute; using Deal.Tests.Unit.Modules.Settings; using Microsoft.Extensions.Logging.Abstractions; @@ -28,9 +29,9 @@ public sealed class AdminTickOrchestratorTests // Контекст теста: оркестратор на общих фейках + канал подписки. private sealed record Context( AdminTickOrchestrator Orchestrator, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, + TestPipelineStore PipelineStore, + TestKanjStore KanjStore, + TestSettingsStore Settings, TestAiClassifier AiClassifier, PipelinePumpGate PumpGate, SseSubscription Subscription); @@ -158,7 +159,7 @@ public sealed class AdminTickOrchestratorTests Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); } [Fact] @@ -194,33 +195,39 @@ public sealed class AdminTickOrchestratorTests // Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings, // обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService); - // ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»). + // ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»). // withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump). // withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки). // Возвращает: Оркестратор, фейки и подписку канала тенанта. private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false) { - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); // При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст). - FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient(); + TestPipelineStore store = new(); + if (withThrowingQueueRead) + { + store.Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>(_ => + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).")); + } + var kanjStore = new TestKanjStore(); + var mlClient = new TestMlClient(); var aiClassifier = new TestAiClassifier(); - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - var ingest = new PipelineIngestService(store); - var processing = new PipelineProcessingService(store, mlClient, ingest); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); + var rules = new IncomingRules(settings.Store); + var fieldsParser = new LocalFieldsParser(settings.Store); + var ingest = new PipelineIngestService(store.Store); + var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest); + var composer = new CardComposer(kanjStore.Store, settings.Store); + var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer); var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); + store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser); var broker = new SseBroker(); - var tickService = new StorageTickService(kanjStore, settings); + var tickService = new StorageTickService(kanjStore.Store, settings.Store); var toastPublisher = new StorageToastPublisher(broker); var pumpGate = new PipelinePumpGate(); - FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore; - var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage); + TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore; + var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage); var orchestrator = new AdminTickOrchestrator( tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate, NullLogger.Instance); @@ -229,16 +236,7 @@ public sealed class AdminTickOrchestratorTests broker.Subscribe(TenantA)); } - // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий - // «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст). - private sealed class ThrowingDueKanjStore : FakeKanjStore - { - /// - public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); - } - } + // Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore. // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -261,19 +259,6 @@ public sealed class AdminTickOrchestratorTests }; } - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } - // Строка очереди (status new; сценарий перекрывает текст/время). private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue( id, diff --git a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs index 18cd384..0243af3 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs @@ -3,6 +3,7 @@ using Deal.Api.Events; using Deal.Api.Hosting; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Tenants; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); await using ServiceProvider provider = BuildProvider( @@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests Assert.Empty(ReadToasts(subscriptionA)); // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). - Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); + Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None)); } [Fact] @@ -74,7 +75,7 @@ public sealed class BudgetAlertSchedulerTests { // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() { - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits); @@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests SseSubscription subscriptionA = broker.Subscribe(TenantA); BudgetAlertScheduler scheduler = CreateScheduler(provider); - await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); + await limits.Store.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); @@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. - await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); + await limits.Store.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); await scheduler.RunCycleAsync(CancellationToken.None); Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); @@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests // tenants: Фейк реестра тенантов (обход прохода). // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) + private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits) { var services = new ServiceCollection(); services.AddSingleton(tenants); - services.AddScoped(_ => limits); + services.AddScoped(_ => limits.Store); services.AddSingleton(); return services.BuildServiceProvider(); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index 900cd3f..d771f6b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; @@ -8,7 +9,6 @@ using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -21,9 +21,9 @@ public sealed class CardReclassifierTests // Контекст теста: сервис поверх in-memory фейков. private sealed record Context( CardReclassifier Reclassifier, - FakeKanjStore Store, - FakeSettingsStore Settings, - FakeMlClient MlClient, + TestKanjStore Store, + TestSettingsStore Settings, + TestMlClient MlClient, TestAiClassifier AiClassifier, ReclassifyGate Gate); @@ -31,16 +31,16 @@ public sealed class CardReclassifierTests // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). private static Context CreateContext() { - var settings = new FakeSettingsStore(); - var store = new FakeKanjStore(); - var mlClient = new FakeMlClient(); + var settings = new TestSettingsStore(); + var store = new TestKanjStore(); + var mlClient = new TestMlClient(); var aiClassifier = new TestAiClassifier(); - var fieldsParser = new LocalFieldsParser(settings); - var composer = new CardComposer(store, settings); - var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage); + var fieldsParser = new LocalFieldsParser(settings.Store); + var composer = new CardComposer(store.Store, settings.Store); + var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage); var gate = new ReclassifyGate(); var reclassifier = new CardReclassifier( - store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate); + store.Store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate); return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index 11ad5f7..cecfa82 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -2,7 +2,6 @@ using System.Text; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); store.SeedCard(Card("c_1", updatedAtMs: 1)); byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); using MemoryStream stream = new(content); @@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1")); CardFileDto? entry = await service.AddFileAsync( @@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); store.SeedCard(Card("c_1")); CardFileDto? first = await service.AddFileAsync( @@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); CardFileDto? entry = await service.AddFileAsync( "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); @@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1")); CardFileDto? entry = await service.AddFileAsync( @@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); store.SeedCard(Card("c_1")); string rawName = "..\\файл\"отчёта v2.pdf"; @@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Add_StreamPositionNotZero_StoresWholeContent() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); store.SeedCard(Card("c_1")); byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше @@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task GetEntry_ExistingFile_ReturnsEntryMeta() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf"); store.SeedCard(Card("c_1") with { Files = new[] { file } }); @@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task GetEntry_FileNotInMetadata_ReturnsNull() { - (CardsService service, FakeKanjStore store, _) = Create(); + (CardsService service, TestKanjStore store, _) = Create(); store.SeedCard(Card("c_1") with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); @@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png"); store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } }); @@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); store.SeedCard(Card("c_1") with { Files = new[] { file } }); @@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests [Fact] public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() { - (CardsService service, FakeKanjStore store, TestFileStorage storage) = Create(); + (CardsService service, TestKanjStore store, TestFileStorage storage) = Create(); store.SeedCard(Card("c_1") with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); @@ -238,11 +237,11 @@ public sealed class CardsServiceFilesTests // ─── Хелперы ────────────────────────────────────────────────────────── - private static (CardsService Service, FakeKanjStore Store, TestFileStorage Storage) Create() + private static (CardsService Service, TestKanjStore Store, TestFileStorage Storage) Create() { - var store = new FakeKanjStore(); + var store = new TestKanjStore(); var storage = new TestFileStorage(); - return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage.Storage), store, storage); + return (new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage); } // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs index 75765cb..e2317c2 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", updatedAtMs: 100)); store.SeedCard(Card("c_2", updatedAtMs: 300)); store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); @@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task List_ByStage_ReturnsOnlyStageCards() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1")); store.SeedCard(Card("c_2", stage: "work")); @@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); CardDto card = await service.CreateLocalCardAsync( new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), @@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); @@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task TakeCard_MovesCardToPlannedKeepingFields() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card( "c_1", stage: "inbox", @@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); @@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card( "c_1", title: "Старый заголовок", @@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Patch_BudgetNull_ClearsBudget() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) with { Budget = new CardBudgetDto(1000, 2000, "USD") }); @@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Patch_StackNull_ClearsStack() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) with { Stack = new[] { "Python" } }); @@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Patch_UnknownKeys_AreIgnored() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Старый")); CardDto? card = await service.PatchCardAsync( @@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Patch_NullTextKey_IsIgnored() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1)); CardDto? card = await service.PatchCardAsync( @@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); long seededAt = 5_000; CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) with @@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", stage: "planned")); CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); @@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", stage: "rejected")); store.SeedCard(Card("c_2", stage: "rejected")); store.SeedCard(Card("c_3", stage: "finished")); @@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); @@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddComment_TwoComments_AppendsPreservingOrder() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка") with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); @@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка")); AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); @@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddComment_CardMissing_ReturnsNullComments() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None); @@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); @@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка")); CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); @@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка")); CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); @@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); @@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) with { @@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("c_1", title: "Карточка") with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); @@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests [Fact] public async Task RemoveLink_CardMissing_ReturnsNullCard() { - (CardsService service, FakeKanjStore store, _, _) = Create(); + (CardsService service, TestKanjStore store, _, _) = Create(); CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); @@ -531,12 +530,12 @@ public sealed class CardsServiceSelectedTests // ─── Хелперы ────────────────────────────────────────────────────────── - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() + private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var ml = new FakeMlClient(); - return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + var ml = new TestMlClient(); + return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml); } // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). diff --git a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs index 8ef8600..a329ea2 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -29,7 +28,7 @@ public sealed class ContainersServiceTests [Fact] public async Task Create_ExistingContainers_OrderIsMaxPlusOne() { - (ContainersService service, FakeKanjStore store, _) = Create(); + (ContainersService service, TestKanjStore store, _) = Create(); store.SeedBoard(Container("b_2", 2)); store.SeedBoard(Container("b_5", 5)); @@ -43,7 +42,7 @@ public sealed class ContainersServiceTests [Fact] public async Task Create_OrderEight_CyclesPaletteToFirst() { - (ContainersService service, FakeKanjStore store, _) = Create(); + (ContainersService service, TestKanjStore store, _) = Create(); store.SeedBoard(Container("b_7", 7)); ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None); @@ -157,7 +156,7 @@ public sealed class ContainersServiceTests [Fact] public async Task List_FillsCountsFromCards() { - (ContainersService service, FakeKanjStore store, _) = Create(); + (ContainersService service, TestKanjStore store, _) = Create(); ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None); store.SeedCard(new CardDto { Id = "c_1", Col = container.Id, IsNew = true }); store.SeedCard(new CardDto { Id = "c_2", Col = container.Id, IsNew = true }); @@ -283,16 +282,16 @@ public sealed class ContainersServiceTests [Fact] public async Task Reorder_AssignsOrdersInGivenOrder() { - (ContainersService service, FakeKanjStore store, _) = Create(); + (ContainersService service, TestKanjStore store, _) = Create(); store.SeedBoard(Container("b_1", 0)); store.SeedBoard(Container("b_2", 1)); store.SeedBoard(Container("b_3", 2)); await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None); - Assert.Equal(0, (await store.GetContainerAsync("b_3", CancellationToken.None))!.Order); - Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order); - Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order); + Assert.Equal(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order); + Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order); + Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order); } // ─── Удаление контейнера ────────────────────────────────────────────── @@ -300,7 +299,7 @@ public sealed class ContainersServiceTests [Fact] public async Task Delete_MovesCardsToInbox_ReturnsMovedCount() { - (ContainersService service, FakeKanjStore store, _) = Create(); + (ContainersService service, TestKanjStore store, _) = Create(); ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None); store.AddCard("c_1", container.Id); store.AddCard("c_2", container.Id); @@ -309,7 +308,7 @@ public sealed class ContainersServiceTests int moved = await service.DeleteAsync(container.Id, CancellationToken.None); Assert.Equal(2, moved); - Assert.Null(await store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён + Assert.Null(await store.Store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён Assert.Contains(store.Cards, card => card.CardId == "c_1" && card.Col == "inbox" && card.IsNew); Assert.Contains(store.Cards, card => card.CardId == "c_2" && card.Col == "inbox" && card.IsNew); Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут @@ -340,7 +339,7 @@ public sealed class ContainersServiceTests [Fact] public async Task GetColState_ReturnsStoredColumnsAsIs() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}"""); IReadOnlyDictionary state = await service.GetColStateAsync(CancellationToken.None); @@ -353,7 +352,7 @@ public sealed class ContainersServiceTests [Fact] public async Task PatchColState_NewColumn_WritesStateAndReturnsIt() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); ColumnStateDto result = await service.PatchColStateAsync( "inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None); @@ -368,7 +367,7 @@ public sealed class ContainersServiceTests [Fact] public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); settings.Preload("colState", """{"inbox":{"collapsed":true}}"""); ColumnStateDto result = await service.PatchColStateAsync( @@ -380,7 +379,7 @@ public sealed class ContainersServiceTests [Fact] public async Task PatchColState_NullFields_KeepCurrentState() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}"""); ColumnStateDto result = await service.PatchColStateAsync( @@ -392,7 +391,7 @@ public sealed class ContainersServiceTests [Fact] public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); settings.Preload("colState", """{"archive":{"collapsed":true}}"""); await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None); @@ -408,7 +407,7 @@ public sealed class ContainersServiceTests [Fact] public async Task GetColState_CorruptJson_ReturnsEmpty() { - (ContainersService service, _, FakeSettingsStore settings) = Create(); + (ContainersService service, _, TestSettingsStore settings) = Create(); settings.Preload("colState", "{oops"); IReadOnlyDictionary state = await service.GetColStateAsync(CancellationToken.None); @@ -418,11 +417,11 @@ public sealed class ContainersServiceTests // ─── Хелперы ────────────────────────────────────────────────────────── - private static (ContainersService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() + private static (ContainersService Service, TestKanjStore Store, TestSettingsStore Settings) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new ContainersService(store, settings), store, settings); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + return (new ContainersService(store.Store, settings.Store), store, settings); } // Патч только с нужными полями: остальные параметры null = «не менять». diff --git a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs index 49f660e..a885ee0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs @@ -3,8 +3,8 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -19,7 +19,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_MockRates_ConvertsUsdBudgetToRub() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD")); @@ -34,7 +34,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey) store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -49,7 +49,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -62,7 +62,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_UsdtBudget_TreatedAsUsdRate() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); var rates = new Dictionary { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 }; settings.Preload(SettingsKeys.RatesCache, CacheJson(rates)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT")); @@ -78,7 +78,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD")); @@ -93,7 +93,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.ConversionOn, "false"); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -109,7 +109,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_ExcludesArchiveAndTrashCards() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD")); @@ -128,7 +128,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary { ["RUB"] = 1.0, ["USD"] = 92.5 })); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with { @@ -147,7 +147,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched() { - (ConversionRecomputer service, FakeKanjStore store, _) = Create(); + (ConversionRecomputer service, TestKanjStore store, _) = Create(); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); int updated = await service.RecomputeAsync(CancellationToken.None); @@ -159,7 +159,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_CorruptedRatesCache_ReturnsZero() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, "{это не JSON"); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -174,7 +174,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task OnRatesChangedAsync_PerformsFullRecompute() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -189,7 +189,7 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_SecondRun_RecomputesSameValues() { - (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + (ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -203,11 +203,11 @@ public sealed class ConversionRecomputerTests // ─── Хелперы ──────────────────────────────────────────────────────────── - private static (ConversionRecomputer Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() + private static (ConversionRecomputer Service, TestKanjStore Store, TestSettingsStore Settings) Create() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new ConversionRecomputer(settings, store), store, settings); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + return (new ConversionRecomputer(settings.Store, store.Store), store, settings); } // Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера). @@ -237,7 +237,7 @@ public sealed class ConversionRecomputerTests // Конверсия (Converted) карточки в фейке после пересчёта. // store: Фейк-хранилище. // cardId: Id карточки. - private static CardBudgetDto? ConvertedOf(FakeKanjStore store, string cardId) => + private static CardBudgetDto? ConvertedOf(TestKanjStore store, string cardId) => store.CardDtos.Single(card => card.Id == cardId).Converted; // Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту). @@ -247,7 +247,7 @@ public sealed class ConversionRecomputerTests // convTo: Ожидаемая верхняя граница. // convCur: Ожидаемая валюта конверсии. private static void AssertConverted( - FakeKanjStore store, + TestKanjStore store, string cardId, double convFrom, double convTo, diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 7480205..5457266 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -15,7 +15,6 @@ using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; using Microsoft.Extensions.Logging.Abstractions; @@ -52,12 +51,12 @@ public sealed class GrpcAiClassifierTests Reason = "реклама", Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, }; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}.")); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); @@ -88,7 +87,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.FilterUnavailable = true; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await Assert.ThrowsAsync( @@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { string text = new string('а', 5000); - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await classifier.FilterAsync(text, CancellationToken.None); @@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests """, Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, }; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}.")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); @@ -152,7 +151,7 @@ public sealed class GrpcAiClassifierTests Order = 0, }); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); @@ -191,9 +190,9 @@ public sealed class GrpcAiClassifierTests Ok = false, Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, }; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); - FakeTenantLimitStore limits = new(); + TestTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). @@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.ClassifyUnavailable = true; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await Assert.ThrowsAsync( @@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); settings.Preload( SettingsKeys.AiConfigs, @@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests { service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; string text = new string('б', 6000); - (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await classifier.ClassifyAsync(text, CancellationToken.None); @@ -284,28 +283,29 @@ public sealed class GrpcAiClassifierTests // Возвращает: Экземпляр GrpcAiClassifier. private static GrpcAiClassifier CreateClassifier( int port, - FakeSettingsStore settings, + TestSettingsStore settings, ISecretCipher cipher, - FakeKanjStore kanjStore, - FakeTenantLimitStore? limits = null) + TestKanjStore kanjStore, + TestTenantLimitStore? limits = null) { + limits ??= new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); return new GrpcAiClassifier( tenantContext, connection, - new AiProviderConfigBuilder(settings, cipher), - new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), + new AiProviderConfigBuilder(settings.Store, cipher), + new AiClassifyContextBuilder(settings.Store, kanjStore.Store), + new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). // port: Порт хоста (не используется контекстом — единый вид хелперов). // Возвращает: Кортеж фейков (настройки, шифр, канбан). - private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port) - => (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore()); + private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port) + => (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore()); // Сериализует значение настройки в JSON-строку (как пишет SettingsStore). // value: Значение (строка/список). @@ -313,7 +313,7 @@ public sealed class GrpcAiClassifierTests private static string Json(object value) => JsonSerializer.Serialize(value); private static async Task AssertUsageAsync( - FakeSettingsStore settings, + TestSettingsStore settings, uint prompt, uint completion, uint total) diff --git a/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs index 37648e3..6cba04f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -12,7 +13,7 @@ public sealed class IncomingRulesTests // Создаёт правила поверх in-memory KV-хранилища. // store: Хранилище настроек тенанта. // Возвращает: Экземпляр IncomingRules. - private static IncomingRules CreateRules(FakeSettingsStore store) => new(store); + private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store); // Сериализует значение в JSON-строку строки settings (как пишет SettingsStore). // value: Значение (строка/число/булево/список). @@ -23,7 +24,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength() { - IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) + IncomingRulesResult result = await CreateRules(new TestSettingsStore()) .CheckAsync("Короткий текст", CancellationToken.None); Assert.False(result.Pass); @@ -37,7 +38,7 @@ public sealed class IncomingRulesTests public async Task CheckAsync_EmptyText_IsLengthFail() { // (text or "").strip() — пустой/пробельный текст всегда «короче minLen». - IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) + IncomingRulesResult result = await CreateRules(new TestSettingsStore()) .CheckAsync(" ", CancellationToken.None); Assert.False(result.Pass); @@ -47,7 +48,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MinLen, Json(10)); IncomingRules rules = CreateRules(store); @@ -61,7 +62,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_LongCleanTextWithDefaults_Passes() { - IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync( + IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync( "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу", CancellationToken.None); @@ -75,7 +76,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" })); IncomingRules rules = CreateRules(store); @@ -92,7 +93,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" })); IncomingRules rules = CreateRules(store); @@ -108,7 +109,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); IncomingRules rules = CreateRules(store); @@ -125,7 +126,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_ResumeText_BlockResumesOff_Passes() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); store.Preload(SettingsKeys.BlockResumes, Json(false)); IncomingRules rules = CreateRules(store); @@ -141,7 +142,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); IncomingRules rules = CreateRules(store); @@ -158,7 +159,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); IncomingRules rules = CreateRules(store); @@ -175,7 +176,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); store.Preload(SettingsKeys.WantedType, Json("freelance")); IncomingRules rules = CreateRules(store); @@ -194,7 +195,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); store.Preload(SettingsKeys.WantedType, Json("vacancy")); IncomingRules rules = CreateRules(store); @@ -211,7 +212,7 @@ public sealed class IncomingRulesTests [Fact] public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); store.Preload(SettingsKeys.WantedType, Json("freelance")); IncomingRules rules = CreateRules(store); @@ -229,7 +230,7 @@ public sealed class IncomingRulesTests public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes() { // Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается. - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); IncomingRules rules = CreateRules(store); diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs index b7ac634..220e1cf 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs @@ -37,10 +37,10 @@ public sealed class JoinEndpointHttpTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); var tenantStore = new TestTenantStore(); var provisioner = new TestTenantProvisioner(); - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore, @@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests Assert.False(response.Headers.Contains("Set-Cookie")); }); - // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. + // Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated. StoredUserDto user = Assert.Single(authStore.Users); Assert.Equal(Email, user.Login); Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash); @@ -80,10 +80,10 @@ public sealed class JoinEndpointHttpTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(), async (baseAddress, client) => { using (HttpResponseMessage first = await PostJsonAsync( @@ -108,10 +108,10 @@ public sealed class JoinEndpointHttpTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests public async Task Join_WithUnknownCode_Returns400NotFound() { await RunAsync( - new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), + new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email)); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -201,11 +201,11 @@ public sealed class JoinEndpointHttpTests { var inviteStore = new TestInviteStore(); inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); await RunAsync( - inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), + inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -228,10 +228,10 @@ public sealed class JoinEndpointHttpTests // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var provisioner = new TestTenantProvisioner(); - var authStore = new FakeAuthStore(); + var authStore = new TestAuthStore(); await RunAsync( - inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(), + inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -247,13 +247,13 @@ public sealed class JoinEndpointHttpTests // ─── Хелперы ───────────────────────────────────────────────────────── - // Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. + // Поднимает in-process Kestrel с /api/join на подставках хранилищ и прогоняет сценарий. private static async Task RunAsync( TestInviteStore inviteStore, TestTenantStore tenantStore, ITenantProvisioner provisioner, - FakeAuthStore authStore, - FakeAuditLogStore auditStore, + TestAuthStore authStore, + TestAuditLogStore auditStore, Func scenario) { int port = TestPort.Allocate(); @@ -264,8 +264,8 @@ public sealed class JoinEndpointHttpTests // Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает // последняя регистрация (зеркало OperatorAuthHttpHost). builder.Services.AddSingleton(TestHashers.New()); - builder.Services.AddSingleton(authStore); - builder.Services.AddSingleton(auditStore); + builder.Services.AddSingleton(authStore.Store); + builder.Services.AddSingleton(auditStore.Store); builder.Services.AddSingleton(inviteStore.Store); builder.Services.AddSingleton(tenantStore.Repository); builder.Services.AddSingleton(provisioner); diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs index e84c7e0..480b64a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs @@ -5,7 +5,7 @@ using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -17,18 +17,18 @@ public sealed class LocalColumnSuggesterTests { // Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля. // Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер). - private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext() + private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext() { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings)); + var store = new TestKanjStore(); + var settings = new TestSettingsStore(); + var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store)); return (store, settings, suggester); } private static void SeedInbox( string id, string text, - FakeKanjStore store, + TestKanjStore store, long receivedAtMs = 0) => store.SeedCard(new CardDto { @@ -57,7 +57,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); for (int i = 1; i < SuggestHeuristics.MinInbox; i++) { SeedInbox($"l_{i}", "нужен python", store); @@ -72,7 +72,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason() { - (FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); settings.Preload(SettingsKeys.LastSuggestAt, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); SeedInbox("l_1", "нужен python", store); @@ -87,7 +87,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"]; for (int i = 0; i < uniqueWords.Length; i++) { @@ -103,7 +103,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); store.SeedBoard(new ContainerDto { Id = "b_python", @@ -128,7 +128,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards() { - (FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p3", "нужен python", store); @@ -169,7 +169,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p3", "нужен python", store); @@ -191,7 +191,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p3", "нужен python", store); @@ -209,7 +209,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestKeywords_TooFewCards_ReturnsReason() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_1", "нужен python", store); SeedInbox("l_2", "нужен python", store); @@ -223,7 +223,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_1", "нужен python", store); SeedInbox("l_2", "нужен vue", store); store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } }); @@ -239,7 +239,7 @@ public sealed class LocalColumnSuggesterTests [Fact] public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason() { - (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); + (TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"]; for (int i = 0; i < uniqueWords.Length; i++) { diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs index 98206c0..aa3e59f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs @@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; @@ -27,14 +28,17 @@ public sealed class LocalMlClientTests // store: Хранилище настроек тенанта. // learning: Хранилище обучения (по умолчанию пустое: счётчики 0). // Возвращает: Экземпляр LocalMlClient. - private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) - => new(store, learning ?? new FakeMlLearningStore()); + private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null) + { + learning ??= new TestMlLearningStore(); + return new(store.Store, learning.Store); + } [Fact] public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() { - LocalMlClient client = CreateClient(new FakeSettingsStore()); + LocalMlClient client = CreateClient(new TestSettingsStore()); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); @@ -63,7 +67,7 @@ public sealed class LocalMlClientTests [Fact] public async Task StatusAsync_StoredCounters_AreReflectedInStats() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.AiDecisions, "3"); LocalMlClient client = CreateClient(store); @@ -78,7 +82,7 @@ public sealed class LocalMlClientTests [Fact] public async Task StatusAsync_StoredMlEnabledFalse_Disables() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlEnabled, "false"); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); @@ -89,7 +93,7 @@ public sealed class LocalMlClientTests [Fact] public async Task StatusAsync_StoredMlEnabledTrue_Enables() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlEnabled, "true"); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); @@ -102,7 +106,7 @@ public sealed class LocalMlClientTests [InlineData("не json {{")] public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson) { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlEnabled, valueJson); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); @@ -113,7 +117,7 @@ public sealed class LocalMlClientTests [Fact] public async Task StatusAsync_DamagedCounter_DegradesToZero() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlDecisions, "не число"); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); @@ -124,8 +128,8 @@ public sealed class LocalMlClientTests [Fact] public async Task StatusAsync_ReportsLearningAndOutboxFromTables() { - var store = new FakeSettingsStore(); - var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи + var store = new TestSettingsStore(); + var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи LocalMlClient client = CreateClient(store, learning); await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None); await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None); @@ -142,7 +146,7 @@ public sealed class LocalMlClientTests [Fact] public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape() { - LocalMlClient client = CreateClient(new FakeSettingsStore()); + LocalMlClient client = CreateClient(new TestSettingsStore()); MlPredictResultDto result = await client.PredictAsync( "Python backend на fastapi, бот в телеграм", CancellationToken.None); @@ -161,8 +165,8 @@ public sealed class LocalMlClientTests [Fact] public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + var learning = new TestMlLearningStore(); + LocalMlClient client = CreateClient(new TestSettingsStore(), learning); await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None); @@ -181,8 +185,8 @@ public sealed class LocalMlClientTests [InlineData("текст", " ")] // label из пробелов — пуста после trim public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label) { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + var learning = new TestMlLearningStore(); + LocalMlClient client = CreateClient(new TestSettingsStore(), learning); await client.PushAsync(text, label, 1.0, CancellationToken.None); @@ -192,8 +196,8 @@ public sealed class LocalMlClientTests [Fact] public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength() { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + var learning = new TestMlLearningStore(); + LocalMlClient client = CreateClient(new TestSettingsStore(), learning); string longText = new string('а', MaxLearningTextLength + 1000); await client.PushAsync(longText, "spam", 1.0, CancellationToken.None); @@ -206,8 +210,8 @@ public sealed class LocalMlClientTests [Fact] public async Task PushAsync_TruncationDoesNotSplitSurrogatePair() { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + var learning = new TestMlLearningStore(); + LocalMlClient client = CreateClient(new TestSettingsStore(), learning); // Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару. string text = new string('x', MaxLearningTextLength - 1) + "🙂"; @@ -222,8 +226,8 @@ public sealed class LocalMlClientTests [Fact] public async Task PushAsync_NegativeDelta_IsStoredAsIs() { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + var learning = new TestMlLearningStore(); + LocalMlClient client = CreateClient(new TestSettingsStore(), learning); await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None); @@ -236,7 +240,7 @@ public sealed class LocalMlClientTests [Fact] public async Task ResetAsync_ReturnsOk() { - MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); + MlResetResultDto result = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None); Assert.True(result.Ok); Assert.Null(result.Error); @@ -245,7 +249,7 @@ public sealed class LocalMlClientTests [Fact] public async Task ResetAsync_DoesNotTouchSettingsRows() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlDecisions, "7"); LocalMlClient client = CreateClient(store); @@ -259,9 +263,9 @@ public sealed class LocalMlClientTests [Fact] public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlDecisions, "7"); - var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи + var learning = new TestMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи LocalMlClient client = CreateClient(store, learning); await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None); await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None); @@ -278,7 +282,7 @@ public sealed class LocalMlClientTests [Fact] public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat() { - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.MlEnabled, "false"); store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.AiDecisions, "3"); @@ -316,7 +320,7 @@ public sealed class LocalMlClientTests [Fact] public async Task MlPredictResultDto_SerializesToPrototypeWireFormat() { - MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None); + MlPredictResultDto dto = await CreateClient(new TestSettingsStore()).PredictAsync("текст", CancellationToken.None); string json = JsonSerializer.Serialize(dto, CamelCaseOptions); @@ -329,7 +333,7 @@ public sealed class LocalMlClientTests [Fact] public async Task MlResetResultDto_Success_SerializesWithoutErrorKey() { - MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); + MlResetResultDto dto = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None); string json = JsonSerializer.Serialize(dto, CamelCaseOptions); diff --git a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs index 320be4b..d7dc66f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs @@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail() { await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), + new TestOperatorAuthStore(), NewUserStore(), async (baseAddress, _, users, _, _, _, _) => { @@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked() { await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), + new TestOperatorAuthStore(), NewUserStore(), async (baseAddress, _, users, _, _, _, _) => { @@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests using var capture = new SecurityEventCapture(); await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), + new TestOperatorAuthStore(), NewUserStore(), async (baseAddress, _, _, _, _, _, _) => { @@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests new() { Enabled = true }; // Фейк-хранилище пользователей с активным пользователем Login/Password. - private static FakeAuthStore NewUserStore() + private static TestAuthStore NewUserStore() { var passwordHasher = TestHashers.New(); - var store = new FakeAuthStore(); + var store = new TestAuthStore(); store.AddUser(new StoredUserDto( Guid.NewGuid(), Login, diff --git a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs index 13a7af9..eb66a44 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs @@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; @@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests public async Task ParseAsync_ReadsMarkerOverridesFromSettings() { // Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер. - var store = new FakeSettingsStore(); + var store = new TestSettingsStore(); store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" })); - LocalFieldsParser parser = new(store); + LocalFieldsParser parser = new(store.Store); LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default); LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default); @@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests [Fact] public async Task ParseAsync_NoOverrides_UsesDefaults() { - LocalFieldsParser parser = new(new FakeSettingsStore()); + LocalFieldsParser parser = new(new TestSettingsStore().Store); LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs index 22f4fcc..d4379b6 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs @@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests [Fact] public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); @@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests [Fact] public async Task Suspicious_ReturnsFindingsForFailedLoginBurst() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) { - await auditStore.AppendAsync( + await auditStore.Store.AppendAsync( new AuditRecordDto( AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, @@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests [Fact] public async Task Activity_FiltersByActorIdAndPaginates() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); Guid actor = Guid.NewGuid(); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); @@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests [Fact] public async Task Audit_FiltersByActorIdAndOffset() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); Guid actor = Guid.NewGuid(); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); @@ -256,32 +256,32 @@ public sealed class OperatorAnalyticsEndpointsHttpTests new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); return store; } - private static FakeAuthStore NewUserStore() + private static TestAuthStore NewUserStore() { var passwordHasher = TestHashers.New(); - var store = new FakeAuthStore(); + var store = new TestAuthStore(); store.AddUser(new StoredUserDto( Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); return store; } private static Task SeedAuditAsync( - FakeAuditLogStore store, + TestAuditLogStore store, string eventType, string actorType, Guid? tenantId, Guid? actorId = null, int minutesAgo = 1) => - store.AppendAsync( + store.Store.AppendAsync( new AuditRecordDto( eventType, actorType, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs index 1ee669d..4c80ebe 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs @@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task Audit_ReturnsItemsNewestFirstWithTotal() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); // Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC). - await auditStore.AppendAsync(new AuditRecordDto( + await auditStore.Store.AppendAsync(new AuditRecordDto( AuditEvents.TenantCreated, AuditActorTypes.Operator, ActorId: Guid.NewGuid(), @@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task Audit_FiltersByEventTypeAndActorType() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task TenantLogout_WritesTenantLogoutAuditRecord() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests [Fact] public async Task OperatorLogout_WritesOperatorLogoutAuditRecord() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests // ─── Хелперы ───────────────────────────────────────────────────────── // Фейк-хранилище оператора с активным operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, @@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests } // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. - private static FakeAuthStore NewUserStore() + private static TestAuthStore NewUserStore() { var passwordHasher = TestHashers.New(); - var store = new FakeAuthStore(); + var store = new TestAuthStore(); store.AddUser(new StoredUserDto( Guid.NewGuid(), UserLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs index cef04fd..336b7e7 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs @@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests [Fact] public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401() { - FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false); + TestOperatorAuthStore operatorStore = NewOperatorStore(active: false); await OperatorAuthHttpHost.RunAsync( operatorStore, @@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests } // Фейк-хранилище оператора с активным (или нет) оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore(bool active = true) + private static TestOperatorAuthStore NewOperatorStore(bool active = true) { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, @@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests } // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. - private static FakeAuthStore NewUserStore() + private static TestAuthStore NewUserStore() { var passwordHasher = TestHashers.New(); - var store = new FakeAuthStore(); + var store = new TestAuthStore(); store.AddUser(new StoredUserDto( Guid.NewGuid(), UserLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index cbbca52..79c569f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -33,17 +33,17 @@ internal static class OperatorAuthHttpHost "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3"; /// - /// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей. + /// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей. /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Подставка хранилища оператора (IOperatorAuthStore). + /// Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth. /// Сценарий: получает base-адрес и хранилища для проверок/досидирования. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри. public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null) => + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + Func scenario, + TestAuditLogStore? auditStore = null) => await RunCoreAsync( operatorStore, userStore, @@ -56,16 +56,16 @@ internal static class OperatorAuthHttpHost /// /// Прогоняет сценарий /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Подставка хранилища оператора (IOperatorAuthStore). + /// Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth. /// Сценарий: получает base-адрес и хранилища (без реестра тенантов). - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. + /// Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри. public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + Func scenario, + TestAuditLogStore? auditStore = null, TestInviteStore? inviteStore = null) => await RunCoreAsync( operatorStore, @@ -79,17 +79,17 @@ internal static class OperatorAuthHttpHost /// /// Прогоняет сценарий /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Подставка хранилища оператора (IOperatorAuthStore). + /// Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth. /// Сценарий: получает base-адрес и все хранилища, кроме лимитов. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. - /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. + /// Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри. + /// Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри. public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + Func scenario, + TestAuditLogStore? auditStore = null, TestInviteStore? inviteStore = null, TestTenantStore? tenantStore = null) => await RunCoreAsync( @@ -104,22 +104,22 @@ internal static class OperatorAuthHttpHost /// /// Прогоняет сценарий /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Подставка хранилища оператора (IOperatorAuthStore). + /// Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth. /// Сценарий: получает base-адрес и все хранилища, включая лимиты. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. - /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. - /// Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри. + /// Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри. + /// Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри. + /// Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри. /// Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа. public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + Func scenario, + TestAuditLogStore? auditStore = null, TestInviteStore? inviteStore = null, TestTenantStore? tenantStore = null, - FakeTenantLimitStore? limitStore = null, + TestTenantLimitStore? limitStore = null, RateLimitOptions? rateLimitOptions = null, TestTokenUsageEventStore? tokenUsageStore = null) => await RunCoreAsync( @@ -136,16 +136,16 @@ internal static class OperatorAuthHttpHost /// /// Прогоняет сценарий с глобальными настройками /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Подставка хранилища оператора (IOperatorAuthStore). + /// Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth. /// Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое. + /// Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое. public static async Task RunWithGlobalSettingsAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + Func scenario, + TestAuditLogStore? auditStore = null, TestGlobalSettingsStore? globalSettingsStore = null) => await RunCoreAsync( operatorStore, @@ -159,21 +159,21 @@ internal static class OperatorAuthHttpHost // Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки). private static async Task RunCoreAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - FakeAuditLogStore? auditStore, + TestOperatorAuthStore operatorStore, + TestAuthStore userStore, + TestAuditLogStore? auditStore, TestInviteStore? inviteStore, TestTenantStore? tenantStore, - FakeTenantLimitStore? limitStore, - Func scenario, + TestTenantLimitStore? limitStore, + Func scenario, RateLimitOptions? rateLimitOptions = null, TestTokenUsageEventStore? tokenUsageStore = null, TestGlobalSettingsStore? globalSettingsStore = null) { - FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); + TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore(); TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore(); TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore(); - FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); + TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore(); TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore(); TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore(); RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); @@ -186,12 +186,12 @@ internal static class OperatorAuthHttpHost // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(); - builder.Services.AddSingleton(userStore); - builder.Services.AddSingleton(operatorStore); - builder.Services.AddSingleton(effectiveAuditStore); + builder.Services.AddSingleton(userStore.Store); + builder.Services.AddSingleton(operatorStore.Store); + builder.Services.AddSingleton(effectiveAuditStore.Store); builder.Services.AddSingleton(effectiveInviteStore.Store); builder.Services.AddSingleton(effectiveTenantStore.Repository); - builder.Services.AddSingleton(effectiveLimitStore); + builder.Services.AddSingleton(effectiveLimitStore.Store); builder.Services.AddSingleton(effectiveGlobalSettingsStore.Store); builder.Services.AddSingleton(TestCiphers.New()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs index 2113f4b..d04fbd8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs @@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests } // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). - private static Task RunAsync(Func scenario) => - OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); + private static Task RunAsync(Func scenario) => + OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario); // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs index 51a9050..4c99114 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs @@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests [Fact] public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), @@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests } // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, @@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests } // Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются). - private static FakeAuthStore NewUserStore() => new(); + private static TestAuthStore NewUserStore() => new(); // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). private static HttpClient CreateClient(string baseAddress) => diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs index 0809e1a..7bee4dd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs @@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests [Fact] public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); const long newBudget = 20_000_000; await RunAsync( @@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests [Fact] public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( async (baseAddress, operatorStore, _, _, _, _, _) => @@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests [Fact] public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); const long budget = 25_000_000; await RunAsync( @@ -284,19 +284,19 @@ public sealed class OperatorLimitsEndpointsHttpTests $"{baseAddress}/api/operator/tenants/{tenantId}/limit"; // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. - private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null) + private static Task RunAsync(Func scenario, TestAuditLogStore? auditStore = null) { // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). var tenantStore = new TestTenantStore( new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); - var limitStore = new FakeTenantLimitStore(); + var limitStore = new TestTenantLimitStore(); limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true); limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); return OperatorAuthHttpHost.RunAsync( NewOperatorStore(), - new FakeAuthStore(), + new TestAuthStore(), scenario, auditStore, tenantStore: tenantStore, @@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests } // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs index 6bc8cf3..1d580de 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs @@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests } // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. - private static Task RunAsync(TestTenantStore tenantStore, Func scenario) => - OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); + private static Task RunAsync(TestTenantStore tenantStore, Func scenario) => + OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore); // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs index ef1d402..4280c87 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs @@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests [Fact] public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( async (baseAddress, operatorStore, _, globalSettings, audit) => @@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests $"{baseAddress}/api/operator/settings/telegram-keys"; // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. - private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null) => + private static Task RunAsync(Func scenario, TestAuditLogStore? auditStore = null) => OperatorAuthHttpHost.RunWithGlobalSettingsAsync( NewOperatorStore(), - new FakeAuthStore(), + new TestAuthStore(), scenario, auditStore); @@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests } // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs index 8423267..5579f7f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs @@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests [Fact] public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( async (baseAddress, operatorStore, _, tenantStore, _) => @@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests [Fact] public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); const string ownerEmail = "owner@created.com"; await RunAsync( @@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests [Fact] public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( async (baseAddress, operatorStore, userStore, tenantStore, _) => @@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests [Fact] public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); var tenantStore = new TestTenantStore( new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow)); @@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests [Fact] public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit() { - var auditStore = new FakeAuditLogStore(); + var auditStore = new TestAuditLogStore(); await RunAsync( async (baseAddress, operatorStore, userStore, _, _) => @@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. private static Task RunAsync( - Func scenario, - FakeAuditLogStore? auditStore = null, + Func scenario, + TestAuditLogStore? auditStore = null, TestTenantStore? tenantStore = null) { // По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore). @@ -503,8 +503,8 @@ public sealed class OperatorTenantsEndpointsHttpTests // Проверяет запись tenant_status_changed с заданным статусом и актором-оператором. private static void AssertStatusChangedAudit( - FakeAuditLogStore auditStore, - FakeOperatorAuthStore operatorStore, + TestAuditLogStore auditStore, + TestOperatorAuthStore operatorStore, string status) { // В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу. @@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests } // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() + private static TestOperatorAuthStore NewOperatorStore() { var passwordHasher = TestHashers.New(); - var store = new FakeOperatorAuthStore(); + var store = new TestOperatorAuthStore(); store.AddOperator(new StoredOperatorDto( Guid.NewGuid(), OperatorLogin, @@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests } // Фейк-хранилище пользователей с одним пользователем в целевом тенанте. - private static FakeAuthStore NewUserStore() + private static TestAuthStore NewUserStore() { var passwordHasher = TestHashers.New(); - var store = new FakeAuthStore(); + var store = new TestAuthStore(); store.AddUser(new StoredUserDto( Guid.NewGuid(), TenantUserLogin, diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index 6e70d73..9bd8504 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -3,6 +3,7 @@ using Deal.Api.Events; using Deal.Api.Services; using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; +using Deal.Tests.Unit.Support; using Deal.Infrastructure.Data; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Models; @@ -17,7 +18,6 @@ using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; @@ -40,11 +40,11 @@ public sealed class PipelineWorkerSchedulerTests // Контекст теста: планировщик на общих фейках + каналы подписок тенантов. private sealed record Context( PipelineWorkerScheduler Scheduler, - FakePipelineStore PipelineA, - FakeKanjStore KanjA, + TestPipelineStore PipelineA, + TestKanjStore KanjA, SseSubscription SubscriptionA, - FakePipelineStore PipelineB, - FakeKanjStore KanjB, + TestPipelineStore PipelineB, + TestKanjStore KanjB, SseSubscription SubscriptionB, PipelinePumpGate PumpGate, TenantContext TenantContext, @@ -150,12 +150,12 @@ public sealed class PipelineWorkerSchedulerTests { var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenantContext = new TenantContext(); - FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); - var pipelineB = new FakePipelineStore(); - var kanjA = new FakeKanjStore(); - var kanjB = new FakeKanjStore(); - var settingsA = new FakeSettingsStore(); - var settingsB = new FakeSettingsStore(); + var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA); + var pipelineB = new TestPipelineStore(); + var kanjA = new TestKanjStore(); + var kanjB = new TestKanjStore(); + var settingsA = new TestSettingsStore(); + var settingsB = new TestSettingsStore(); var pumpGate = new PipelinePumpGate(); var aiClassifier = new TestAiClassifier { @@ -167,13 +167,13 @@ public sealed class PipelineWorkerSchedulerTests services.AddSingleton(); services.AddSingleton(tenants.Repository); services.AddSingleton(pumpGate); - services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new TestMlClient().Client); services.AddSingleton(aiClassifier.Classifier); // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). - services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store); + services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store); + services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store); // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. services.AddScoped(); services.AddScoped(); @@ -266,18 +266,7 @@ public sealed class PipelineWorkerSchedulerTests return cardIds; } - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } + // Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore. // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). private sealed class ListLogger : ILogger diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index 1e5bb56..019c229 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -1,12 +1,12 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; @@ -24,29 +24,29 @@ public sealed class PipelineWorkerServiceTests // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. private sealed record Context( PipelineWorkerService Worker, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeMlClient MlClient, + TestPipelineStore PipelineStore, + TestKanjStore KanjStore, + TestSettingsStore Settings, + TestMlClient MlClient, TestAiClassifier AiClassifier); // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). - private static Context CreateContext(FakePipelineStore? pipelineStore = null) + private static Context CreateContext(TestPipelineStore? pipelineStore = null) { - var settings = new FakeSettingsStore(); - var store = pipelineStore ?? new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; + var settings = new TestSettingsStore(); + var store = pipelineStore ?? new TestPipelineStore(); + var kanjStore = new TestKanjStore(); + var mlClient = new TestMlClient { Predict = NotReadyPrediction() }; var aiClassifier = new TestAiClassifier(); - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); + var rules = new IncomingRules(settings.Store); + var fieldsParser = new LocalFieldsParser(settings.Store); + var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store)); + var composer = new CardComposer(kanjStore.Store, settings.Store); + var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer); var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); + store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser); return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); } @@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). - private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore - { - /// - /// Сколько раз вызван ClaimAsync - /// - public int ClaimCalls { get; private set; } - /// - /// Сколько раз вызван DeleteClaimAsync - /// - public int DeleteClaimCalls { get; private set; } - - /// - public override Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки - } - - /// - public override Task ClaimAsync(string hash, CancellationToken ct) - { - ClaimCalls++; - return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false - } - - /// - public override Task DeleteClaimAsync(string hash, CancellationToken ct) - { - DeleteClaimCalls++; - return Task.CompletedTask; - } - } // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). // value: Значение (число/булево/строка/список). @@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests [Fact] public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() { - Context ctx = CreateContext(new RacingClaimPipelineStore()); + Context ctx = CreateContext(new TestPipelineStore(claimRace: true)); const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); + TestPipelineStore store = ctx.PipelineStore; Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); Assert.Equal("dup", rejected.Stage); @@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests Assert.Empty(ctx.KanjStore.CardDtos); Assert.Equal(1, result.NoBudget); Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) - Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); + Assert.False(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); } @@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился - Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте + Assert.True(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте Assert.Empty(ctx.PipelineStore.Rejected); Assert.Empty(ctx.KanjStore.CardDtos); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs index 3476c9c..de8a466 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; using NSubstitute; @@ -14,7 +15,7 @@ public sealed class RatesServiceTests { private const long FixedTimestampMs = 1_752_000_000_000; - private readonly FakeSettingsStore _store = new(); + private readonly TestSettingsStore _store = new(); // ─── GetAsync: кэш и дефолт ───────────────────────────────────────────── @@ -418,7 +419,7 @@ public sealed class RatesServiceTests private RatesService CreateService(IRatesSource source, IReadOnlyList listeners) { - return new RatesService(_store, source, listeners); + return new RatesService(_store.Store, source, listeners); } // JSON значения ratesCache с одним курсом USD и заданным временем/источником. diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs index 680dd13..d0c8c13 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; using NSubstitute; @@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support; /// public sealed class SettingsServiceTests { - private readonly FakeSettingsStore _store = new(); + private readonly TestSettingsStore _store = new(); private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly SettingsService _service; public SettingsServiceTests() { - _service = new SettingsService(_store, _cipher, Array.Empty()); + _service = new SettingsService(_store.Store, _cipher, Array.Empty()); } // ─── GET: снимок ───────────────────────────────────────────────────────── @@ -592,7 +593,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() { var listener = Substitute.For(); - SettingsService local = new(_store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, _cipher, new[] { listener }); PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None); @@ -605,7 +606,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners() { var listener = Substitute.For(); - SettingsService local = new(_store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, _cipher, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None); @@ -617,7 +618,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners() { var listener = Substitute.For(); - SettingsService local = new(_store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, _cipher, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None); @@ -628,7 +629,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners() { var listener = Substitute.For(); - SettingsService local = new(_store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, _cipher, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new Dictionary { ["targetCurrency"] = null }), CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs index 8828b88..4e62467 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs @@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); await RunAsync( registry, - services => services.AddScoped(_ => store), + services => services.AddScoped(_ => store.Store), async channel => { PushSourceReply reply = await PushAsync( @@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); await RunAsync( registry, - services => services.AddScoped(_ => store), + services => services.AddScoped(_ => store.Store), async channel => { PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); @@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); await RunAsync( registry, - services => services.AddScoped(_ => store), + services => services.AddScoped(_ => store.Store), async channel => { PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken); @@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); await RunAsync( registry, - services => services.AddScoped(_ => store), + services => services.AddScoped(_ => store.Store), async channel => { PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken); @@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_WithoutToken_IsUnauthenticated() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null)); @@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_WithWrongToken_IsUnauthenticated() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token")); @@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_UnsetEnvToken_FailsClosed() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); await TelegramIngressTestHost.RunAsync( @@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests configureServices: services => { services.AddSingleton(registry); - services.AddScoped(_ => store); + services.AddScoped(_ => store.Store); }, scenario: async channel => { @@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests [Fact] public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated() { - var store = new FakePipelineStore(); + var store = new TestPipelineStore(); var registry = TestRegistry.With(Tenant(TenantA)); - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken)); diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index 8f0538e..e694a76 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -11,12 +11,12 @@ using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; @@ -42,9 +42,9 @@ public sealed class StorageTickSchedulerTests [Fact] public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var settings = new FakeSettingsStore(); + TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var settings = new TestSettingsStore(); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); @@ -70,10 +70,10 @@ public sealed class StorageTickSchedulerTests [Fact] public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - var storeB = new FakeKanjStore(); + TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + var storeB = new TestKanjStore(); storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), @@ -99,16 +99,16 @@ public sealed class StorageTickSchedulerTests public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() { // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); var tenantContext = new TenantContext(); var settingsByTenant = new Dictionary { [TenantA] = new ThrowingSettingsStore(), - [TenantB] = new FakeSettingsStore(), + [TenantB] = new TestSettingsStore().Store, }; - var storesByTenant = new Dictionary + var storesByTenant = new Dictionary { - [TenantA] = new FakeKanjStore(), + [TenantA] = new TestKanjStore(), [TenantB] = storeB, }; await using ServiceProvider provider = BuildProvider( @@ -137,7 +137,7 @@ public sealed class StorageTickSchedulerTests await using ServiceProvider provider = BuildProvider( new ThrowingTenantRepository(), tenantContext, - new Dictionary(), + new Dictionary(), new Dictionary()); StorageTickScheduler scheduler = CreateScheduler(provider); @@ -150,19 +150,19 @@ public sealed class StorageTickSchedulerTests [Fact] public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() { - var kanjStore = new FakeKanjStore(); - var settings = new FakeSettingsStore(); + var kanjStore = new TestKanjStore(); + var settings = new TestSettingsStore(); var tenantContext = new TenantContext(); - var pipelineStoreA = new FakePipelineStore(); + var pipelineStoreA = new TestPipelineStore(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); await using ServiceProvider provider = BuildProvider( new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, tenantContext, - new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }, - new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); + new Dictionary { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() }, + new Dictionary { [TenantA] = settings.Store, [TenantB] = settings.Store }, + new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -181,18 +181,18 @@ public sealed class StorageTickSchedulerTests [Fact] public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() { - var cardStoreA = new FakeKanjStore(); + var cardStoreA = new TestKanjStore(); cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000)); cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); - var cardStoreB = new FakeKanjStore(); + var cardStoreB = new TestKanjStore(); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); + new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, + new Dictionary { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -210,24 +210,24 @@ public sealed class StorageTickSchedulerTests Assert.False(subscriptionB.Events.TryRead(out _)); // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + Assert.Empty(await cardStoreA.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); Assert.False(tenantContext.HasTenant); } [Fact] public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() { - var cardStoreA = new FakeKanjStore(); + var cardStoreA = new TestKanjStore(); cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); - var settingsA = new FakeSettingsStore(); + var settingsA = new TestSettingsStore(); settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( new TestTenantRepository(Tenant(TenantA)).Repository, tenantContext, - new Dictionary { [TenantA] = cardStoreA }, - new Dictionary { [TenantA] = settingsA }, - new Dictionary { [TenantA] = new() }); + new Dictionary { [TenantA] = cardStoreA }, + new Dictionary { [TenantA] = settingsA.Store }, + new Dictionary { [TenantA] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -245,15 +245,15 @@ public sealed class StorageTickSchedulerTests { // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. - var cardStoreA = new ThrowingDueKanjStore(); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var cardStoreA = new TestKanjStore(throwOnDueReminders: true); + TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); var tenantContext = new TenantContext(); await using ServiceProvider provider = BuildProvider( new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); + new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, + new Dictionary { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -281,15 +281,15 @@ public sealed class StorageTickSchedulerTests private static ServiceProvider BuildProvider( TestTenantRepository tenants, TenantContext tenantContext, - FakeKanjStore storeA, - FakeKanjStore storeB, - FakeSettingsStore settings) + TestKanjStore storeA, + TestKanjStore storeB, + TestSettingsStore settings) { return BuildProvider( tenants.Repository, tenantContext, - new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }); + new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, + new Dictionary { [TenantA] = settings.Store, [TenantB] = settings.Store }); } // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. @@ -302,14 +302,14 @@ public sealed class StorageTickSchedulerTests private static ServiceProvider BuildProvider( ITenantRepository tenants, TenantContext tenantContext, - Dictionary storesByTenant, + Dictionary storesByTenant, Dictionary settingsByTenant, - Dictionary? pipelineStoresByTenant = null) + Dictionary? pipelineStoresByTenant = null) { - pipelineStoresByTenant ??= new Dictionary + pipelineStoresByTenant ??= new Dictionary { - [TenantA] = new FakePipelineStore(), - [TenantB] = new FakePipelineStore(), + [TenantA] = new TestPipelineStore(), + [TenantB] = new TestPipelineStore(), }; var services = new ServiceCollection(); @@ -319,10 +319,10 @@ public sealed class StorageTickSchedulerTests services.AddSingleton(tenants); // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. - services.AddScoped(provider => storesByTenant[TenantOf(provider)]); + services.AddScoped(provider => storesByTenant[TenantOf(provider)].Store); services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); - services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); - services.AddSingleton(new FakeMlClient()); + services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)].Store); + services.AddSingleton(new TestMlClient().Client); services.AddSingleton(new TestFileStorage().Storage); services.AddScoped(); services.AddScoped(); @@ -359,17 +359,7 @@ public sealed class StorageTickSchedulerTests private static TenantRecordDto Tenant(Guid id) => new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий - // «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в - // AdminTickOrchestratorTests). - private sealed class ThrowingDueKanjStore : FakeKanjStore - { - /// - public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); - } - } + // Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore. // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -394,9 +384,9 @@ public sealed class StorageTickSchedulerTests // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. // cardId: Id карточки. // Возвращает: Фейк-хранилище с одной старой карточкой inbox. - private static FakeKanjStore StoreWithExpiredInboxCard(string cardId) + private static TestKanjStore StoreWithExpiredInboxCard(string cardId) { - var store = new FakeKanjStore(); + var store = new TestKanjStore(); store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge))); return store; } diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs index f317bb6..005303f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs @@ -3,6 +3,7 @@ using Deal.Api.Events; using Deal.Grpc.Telegram; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Support; using Deal.Modules.Telegram.Application; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; @@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() { var registry = TestRegistry.With(Tenant(TenantA)); - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); settings.Preload(SettingsKeys.AutoMonitorNew, "false"); var dialogs = new TestTelegramStore(); @@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests registry, services => { - services.AddScoped(_ => settings); + services.AddScoped(_ => settings.Store); services.AddScoped(_ => dialogs.Store); }, async channel => @@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests [Fact] public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() { - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); var registry = TestRegistry.With(Tenant(TenantA)); var broker = new SseBroker(); @@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests services => { services.AddSingleton(broker); - services.AddScoped(_ => settings); + services.AddScoped(_ => settings.Store); }, async channel => { @@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests [Fact] public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() { - var settings = new FakeSettingsStore(); + var settings = new TestSettingsStore(); var registry = TestRegistry.With(Tenant(TenantA)); await RunAsync( registry, - services => services.AddScoped(_ => settings), + services => services.AddScoped(_ => settings.Store), async channel => { var client = new IngressService.IngressServiceClient(channel); @@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests // phase: Ожидаемая фаза. // connected: Ожидаемый флаг connected. private static void AssertStatusStored( - FakeSettingsStore settings, + TestSettingsStore settings, string phase, bool connected) { diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs index 341356f..6548585 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost .AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); } - builder.Services.AddScoped(_ => new FakeSettingsStore()); + builder.Services.AddScoped(_ => new TestSettingsStore().Store); builder.Services.AddScoped(_ => new TestTelegramStore().Store); builder.Services.AddScoped(_ => new TestTelegramGateway().Gateway); builder.Services.AddTelegramModule(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs index db95e31..84572e0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs @@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests var store = new TestTelegramStore(); var dialogs = new DialogsService( store.Store, - new FakeSettingsStore(), + new TestSettingsStore().Store, new TestTelegramGateway().Gateway, NullLogger.Instance); var observer = new TelegramSourceIngestObserver(dialogs, NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAuditLogStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAuditLogStore.cs new file mode 100644 index 0000000..f75b181 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAuditLogStore.cs @@ -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; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты проверяют записи через . +/// +public sealed class TestAuditLogStore +{ + private readonly List _records = []; + + /// + /// Подставка порта аудита (создаётся в конструкторе). + /// + public IAuditLogStore Store { get; } + + /// + /// Записи хранилища в порядке добавления. + /// + public IReadOnlyList Records => _records; + + /// + /// Создаёт подставку с пустым журналом. + /// + public TestAuditLogStore() + { + Store = Substitute.For(); + Store.When(s => s.AppendAsync(Arg.Any(), Arg.Any())) + .Do(ci => _records.Add(ci.Arg() with { Id = _records.Count + 1 })); + Store.QueryAsync(Arg.Any(), Arg.Any()) + .Returns(ci => Query(ci.Arg())); + Store.CountAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ApplyFilters(ci.Arg()).Count()); + Store.PurgeOlderThanAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _records.RemoveAll(record => record.At < ci.ArgAt(0))); + } + + private IReadOnlyList 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 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)); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAuthStore.cs new file mode 100644 index 0000000..50925f6 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAuthStore.cs @@ -0,0 +1,127 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют пользователей и сессии через +/// , , , , +/// журнал мутирующих вызовов — . +/// +public sealed class TestAuthStore +{ + private readonly List _users = []; + private readonly List _sessions = []; + private readonly List _calls = []; + + /// + /// Подставка порта аутентификации (создаётся в конструкторе). + /// + public IAuthStore Store { get; } + + /// + /// Пользователи хранилища. + /// + public IReadOnlyList Users => _users; + + /// + /// Сессии хранилища. + /// + public IReadOnlyList Sessions => _sessions; + + /// + /// Журнал мутирующих вызовов в порядке их совершения. + /// + public IReadOnlyList Calls => _calls; + + /// + /// Создаёт подставку с пустыми списками. + /// + public TestAuthStore() + { + Store = Substitute.For(); + Store.FindUserByLoginAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _users.SingleOrDefault(u => u.Login == ci.ArgAt(0))); + Store.FindSessionByTokenHashAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt(0))); + Store.FindUserByIdAsync(Arg.Any(), Arg.Any()) + .Returns(ci => FindById(ci.ArgAt(0))); + Store.ListUsersByTenantIdAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListByTenantId(ci.ArgAt(0))); + Store.When(s => s.CreateUserAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + StoredUserDto user = ci.Arg(); + _users.Add(user); + _calls.Add($"create-user:{user.Id}"); + }); + Store.When(s => s.CreateSessionAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + SessionDto session = ci.Arg(); + _sessions.Add(session); + _calls.Add($"create-session:{session.UserId}"); + }); + Store.When(s => s.DeleteSessionAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + _sessions.RemoveAll(s => s.TokenHash == ci.ArgAt(0)); + _calls.Add($"delete-session:{ci.ArgAt(0)}"); + }); + Store.When(s => s.DeleteSessionsByUserIdAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + _sessions.RemoveAll(s => s.UserId == ci.ArgAt(0)); + _calls.Add($"delete-user-sessions:{ci.ArgAt(0)}"); + }); + Store.When(s => s.UpdatePasswordHashAsync( + Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => UpdatePasswordHash(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any())) + .Do(_ => + { + _sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow); + _calls.Add("delete-expired"); + }); + } + + /// + /// Добавляет пользователя + /// + /// Пользователь. + public void AddUser(StoredUserDto user) => _users.Add(user); + + /// + /// Добавляет сессию + /// + /// Сессия. + 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 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}"); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestKanjStore.cs similarity index 54% rename from src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs rename to src/core/tests/Deal.Tests.Unit/Support/TestKanjStore.cs index 0912d7e..4673b1d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestKanjStore.cs @@ -2,120 +2,262 @@ using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Models; +using NSubstitute; -namespace Deal.Tests.Unit.Modules.Cards; +namespace Deal.Tests.Unit.Support; /// -/// In-memory реализация для unit-тестов сервисов единого домена карточки. +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют состояние через , +/// , , , , +/// , . Сбой записи задаётся . /// -public class FakeKanjStore : ICardStore +public sealed class TestKanjStore { private readonly List _boards = []; private readonly List _cards = []; private readonly List _moves = []; private readonly List<(string Query, int Limit)> _searchCalls = []; - private readonly HashSet _firedById = new(StringComparer.Ordinal); - private readonly Dictionary _archivedAtById = new(StringComparer.Ordinal); + private readonly bool _throwOnDueReminders; /// - /// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests. + /// Подставка порта единого домена карточки (создаётся в конструкторе). + /// + public ICardStore Store { get; } + + /// + /// Доски хранилища (копия на момент обращения). /// public IReadOnlyList Boards => _boards.ToList(); /// - /// Карточки фейка как тройки + /// Карточки хранилища как тройки (id, колонка, флаг «новая»). /// public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards => _cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList(); /// - /// Полные карточки фейка + /// Полные карточки хранилища (копия на момент обращения). /// public IReadOnlyList CardDtos => _cards.ToList(); /// - /// Флаг «сбой записи карточки» + /// Флаг «сбой записи карточки». /// public bool FailAddCard { get; set; } /// - /// Записи журнала CardMoves + /// Записи журнала CardMoves. /// public IReadOnlyList Moves => _moves.ToList(); /// - /// Вызовы SearchCardsAsync как пары + /// Вызовы SearchCardsAsync как пары (запрос, лимит). /// public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList(); + /// + /// Создаёт подставку с пустыми списками. + /// + /// Сценарий сбоя выборки due-напоминаний: ListDueRemindersAsync бросает. + public TestKanjStore(bool throwOnDueReminders = false) + { + _throwOnDueReminders = throwOnDueReminders; + Store = Substitute.For(); + ConfigureContainers(); + ConfigureCards(); + ConfigureComments(); + ConfigureMoves(); + ConfigureStorage(); + ConfigureSelected(); + ConfigureReminders(); + } + /// /// Кладёт доску напрямую /// /// Доска как если бы была сохранена в БД. - public void SeedBoard(ContainerDto board) - { - _boards.Add(board); - } + public void SeedBoard(ContainerDto board) => _boards.Add(board); /// /// Кладёт карточку в колонку. /// /// Id карточки. /// Колонка (inbox/доска). - public void AddCard(string cardId, string col) - { - SeedCard(new CardDto { Id = cardId, Col = col }); - } + public void AddCard(string cardId, string col) => SeedCard(new CardDto { Id = cardId, Col = col }); /// /// Кладёт полную карточку. /// /// Карточка как если бы была сохранена в БД (комментарии — приложенным массивом). - public void SeedCard(CardDto card) + public void SeedCard(CardDto card) => _cards.Add(card); + + /// + /// Задаёт метку архивации карточки + /// + /// Id карточки. + /// Метка архивации (когда карточка ушла в архив). + public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt; + + /// + /// Метка архивации карточки — проверка archived_at после автоархива тика. + /// + /// Id карточки. + /// Метка архивации либо null — карточки нет/не архивирована/метка сброшена. + public DateTimeOffset? ArchivedAtOf(string cardId) => + _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null; + + private void ConfigureContainers() { - _cards.Add(card); + Store.ListContainersAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListContainers(ci.ArgAt(0))); + Store.GetContainerAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _boards.FirstOrDefault(board => board.Id == ci.ArgAt(0))); + Store.When(s => s.CreateContainerAsync(Arg.Any(), Arg.Any())) + .Do(ci => _boards.Add(ci.Arg())); + Store.When(s => s.UpdateContainerAsync(Arg.Any(), Arg.Any())) + .Do(ci => UpdateContainer(ci.Arg())); + Store.DeleteContainerAsync(Arg.Any(), Arg.Any()) + .Returns(ci => DeleteContainer(ci.ArgAt(0))); + Store.When(s => s.ReorderContainersAsync( + Arg.Any(), Arg.Any>(), Arg.Any())) + .Do(ci => ReorderContainers(ci.ArgAt(0), ci.ArgAt>(1))); } - // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── - - /// - public Task> ListContainersAsync(string? space, CancellationToken ct) + private void ConfigureCards() { - IReadOnlyList ordered = _boards + Store.ListCardsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListCards(ci.Arg())); + Store.SearchCardsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => SearchCards(ci.ArgAt(0), ci.ArgAt(1))); + Store.GetCardAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt(0))); + Store.GetCardBySourceAsync(Arg.Any(), Arg.Any()) + .Returns(ci => GetCardBySource(ci.Arg())); + Store.When(s => s.AddCardAsync(Arg.Any(), Arg.Any())) + .Do(ci => AddCard(ci.Arg())); + Store.When(s => s.UpdateColumnAsync(Arg.Any(), Arg.Any())) + .Do(ci => UpdateColumn(ci.Arg())); + Store.ApplyReclassificationAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ApplyReclassification(ci.Arg())); + Store.When(s => s.UpdateSeenAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => UpdateSeen(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.DeleteForeverAsync(Arg.Any(), Arg.Any())) + .Do(ci => DeleteForever(ci.ArgAt(0))); + Store.ClearColAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ClearCol(ci.ArgAt(0))); + Store.CountCardsByColAsync(Arg.Any()) + .Returns(_ => CountCardsByCol()); + } + + private void ConfigureComments() + { + Store.ListCommentsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt(0))?.Comments + ?? Array.Empty()); + Store.When(s => s.AddCommentAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => AddComment( + ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3))); + } + + private void ConfigureMoves() + { + Store.When(s => s.AddMoveAsync(Arg.Any(), Arg.Any())) + .Do(ci => _moves.Add(ci.Arg())); + Store.CountMovesAsync(Arg.Any()).Returns(_ => _moves.Count); + Store.GetAiMarkupExamplesAsync(Arg.Any(), Arg.Any()) + .Returns(ci => GetAiMarkupExamples(ci.ArgAt(0))); + } + + private void ConfigureStorage() + { + Store.ListArchiveCandidatesAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListArchiveCandidates(ci.ArgAt(0))); + Store.ArchiveAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(ci => Archive(ci.ArgAt>(0), ci.ArgAt(1))); + Store.ListExpiredArchiveCandidatesAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListExpiredArchiveCandidates(ci.ArgAt(0))); + Store.ListTrashCandidatesAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListTrashCandidates(ci.ArgAt(0))); + Store.PurgeAsync(Arg.Any>(), Arg.Any()) + .Returns(ci => Purge(ci.ArgAt>(0))); + Store.ListCardsForConversionAsync(Arg.Any()) + .Returns(_ => ListCardsForConversion()); + Store.When(s => s.UpdateConversionAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => UpdateConversion( + ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3))); + } + + private void ConfigureSelected() + { + Store.ListSelectedCardsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListSelectedCards(ci.ArgAt(0))); + Store.PatchCardAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => PatchCard(ci.ArgAt(0), ci.Arg())); + Store.AddLinkAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => AddLink(ci.ArgAt(0), ci.Arg())); + Store.RemoveLinkAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => RemoveLink(ci.ArgAt(0), ci.ArgAt(1))); + Store.AddFileAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => AddFile(ci.ArgAt(0), ci.Arg())); + Store.RemoveFileAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => RemoveFile(ci.ArgAt(0), ci.ArgAt(1))); + Store.MoveCardStageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => MoveCardStage( + ci.ArgAt(0), ci.ArgAt(1), ci.Arg(), ci.ArgAt(3))); + } + + private void ConfigureReminders() + { + Store.When(s => s.SetReminderAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => SetReminder(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.ClearReminderAsync(Arg.Any(), Arg.Any())) + .Do(ci => ClearReminder(ci.ArgAt(0))); + Store.ClearStageAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ClearStage(ci.ArgAt(0))); + if (_throwOnDueReminders) + { + Store.ListDueRemindersAsync(Arg.Any(), Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).")); + } + else + { + Store.ListDueRemindersAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ListDueReminders(ci.ArgAt(0))); + } + + Store.When(s => s.MarkRemindersFiredAsync(Arg.Any>(), Arg.Any())) + .Do(ci => MarkRemindersFired(ci.ArgAt>(0))); + Store.ClearExpiredRemindersAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ClearExpiredReminders(ci.ArgAt(0))); + Store.ListInboxWithSourceAsync(Arg.Any()) + .Returns(_ => ListInboxWithSource()); + } + + private IReadOnlyList ListContainers(string? space) + { + return _boards .Where(board => space is null || board.Space == space) .OrderBy(board => board.Suggested) .ThenBy(board => board.Order) .ToList(); - return Task.FromResult(ordered); } - /// - public Task GetContainerAsync(string containerId, CancellationToken ct) - { - return Task.FromResult(_boards.FirstOrDefault(board => board.Id == containerId)); - } - - /// - public Task CreateContainerAsync(ContainerDto container, CancellationToken ct) - { - _boards.Add(container); - return Task.CompletedTask; - } - - /// - public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct) + private void UpdateContainer(ContainerDto container) { int index = _boards.FindIndex(item => item.Id == container.Id); _boards[index] = container; - return Task.CompletedTask; } - /// - public Task DeleteContainerAsync(string containerId, CancellationToken ct) + private int DeleteContainer(string containerId) { - List moved = _cards.Where(card => card.Col == containerId).ToList(); + int moved = _cards.Count(card => card.Col == containerId); for (int i = 0; i < _cards.Count; i++) { if (_cards[i].Col == containerId) @@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore } _boards.RemoveAll(board => board.Id == containerId); - return Task.FromResult(moved.Count); + return moved; } - /// - public Task ReorderContainersAsync( - string space, - IReadOnlyList containerIds, - CancellationToken ct) + private void ReorderContainers(string space, IReadOnlyList containerIds) { for (int i = 0; i < containerIds.Count; i++) { - string containerId = containerIds[i]; - int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space); + int index = _boards.FindIndex(board => board.Id == containerIds[i] && board.Space == space); if (index >= 0) { _boards[index] = _boards[index] with { Order = i }; } } - - return Task.CompletedTask; } - // ── Карточки ───────────────────────────────────────────────────────── - - /// - public Task> ListCardsAsync(CardsQuery query, CancellationToken ct) + private IReadOnlyList ListCards(CardsQuery query) { IEnumerable result = query.Col is null ? _cards : _cards.Where(card => card.Col == query.Col); - return Task.FromResult>( - result.OrderByDescending(card => card.ReceivedAtMs).ToList()); + return result.OrderByDescending(card => card.ReceivedAtMs).ToList(); } - /// - public Task> SearchCardsAsync( - string q, - int limit, - CancellationToken ct) + private IReadOnlyList SearchCards(string q, int limit) { _searchCalls.Add((q, limit)); string lowered = q.Trim().ToLowerInvariant(); if (lowered.Length == 0) { - return Task.FromResult>(Array.Empty()); + return Array.Empty(); } - IReadOnlyList result = _cards + return _cards .Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) @@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore .OrderByDescending(card => card.ReceivedAtMs) .Take(limit) .ToList(); - return Task.FromResult>(result); } - /// - public Task GetCardAsync(string cardId, CancellationToken ct) - { - return Task.FromResult(_cards.FirstOrDefault(card => card.Id == cardId)); - } - - /// - public Task GetCardBySourceAsync( - SourceRef source, - CancellationToken ct) + private CardDto? GetCardBySource(SourceRef source) { if (string.IsNullOrEmpty(source.Kind)) { - return Task.FromResult(null); + return null; } string externalId = source.ExternalId ?? string.Empty; string originRef = source.OriginRef ?? string.Empty; - CardDto? match = _cards + return _cards .Where(card => card.Source.Kind == source.Kind && (card.Source.ExternalId ?? string.Empty) == externalId && (card.Source.OriginRef ?? string.Empty) == originRef) .OrderByDescending(card => card.ReceivedAtMs) .FirstOrDefault(); - return Task.FromResult(match); } - /// - public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct) + private void AddCard(CardSnapshot snapshot) { if (FailAddCard) { @@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); - return Task.CompletedTask; } - /// - public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) + private void UpdateColumn(CardColumnUpdateDto update) { int index = _cards.FindIndex(card => card.Id == update.CardId); if (index < 0) { - return Task.CompletedTask; + return; } _cards[index] = _cards[index] with @@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore { _archivedAtById.Remove(update.CardId); } - - return Task.CompletedTask; } - /// - public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) + private bool ApplyReclassification(CardReclassificationDto update) { int index = _cards.FindIndex(card => card.Id == update.CardId); if (index < 0) { - return Task.FromResult(false); + return false; } _cards[index] = _cards[index] with @@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore Contacts = update.Contacts, MatchHits = update.MatchHits, }; - return Task.FromResult(true); + return true; } - /// - public Task UpdateSeenAsync( - string? cardId, - string? col, - CancellationToken ct) + private void UpdateSeen(string? cardId, string? col) { for (int i = 0; i < _cards.Count; i++) { - bool matches = cardId is not null - ? _cards[i].Id == cardId - : col is null || _cards[i].Col == col; + bool matches = cardId is not null ? _cards[i].Id == cardId : col is null || _cards[i].Col == col; if (matches && _cards[i].IsNew) { _cards[i] = _cards[i] with { IsNew = false }; } } - - return Task.CompletedTask; } - /// - public Task DeleteForeverAsync(string cardId, CancellationToken ct) + private void DeleteForever(string cardId) { - // Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем. _cards.RemoveAll(card => card.Id == cardId); _archivedAtById.Remove(cardId); - return Task.CompletedTask; } - /// - public Task ClearColAsync(string col, CancellationToken ct) + private int ClearCol(string col) { List removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList(); _cards.RemoveAll(card => card.Col == col); @@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore _archivedAtById.Remove(cardId); } - return Task.FromResult(removedIds.Count); + return removedIds.Count; } - /// - public Task> CountCardsByColAsync(CancellationToken ct) + private IReadOnlyDictionary CountCardsByCol() { - IReadOnlyDictionary result = _cards + return _cards .GroupBy(card => card.Col) .ToDictionary( group => group.Key, group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew))); - return Task.FromResult(result); } - // ── Комментарии (LeadComments) ──────────────────────────────────────── - - /// - public Task> ListCommentsAsync(string cardId, CancellationToken ct) - { - CardDto? card = _cards.FirstOrDefault(item => item.Id == cardId); - return Task.FromResult(card?.Comments ?? Array.Empty()); - } - - /// - public Task AddCommentAsync( - string commentId, - string cardId, - string by, - string text, - CancellationToken ct) + private void AddComment(string commentId, string cardId, string by, string text) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.CompletedTask; + return; } // Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow). CardCommentDto comment = new(commentId, by, text, "только что"); IReadOnlyList updated = _cards[index].Comments.Concat(new[] { comment }).ToList(); _cards[index] = _cards[index] with { Comments = updated }; - return Task.CompletedTask; } - // ── Журнал CardMoves (learning_log) ─────────────────────────────────── - - /// - public Task AddMoveAsync(CardMoveDto move, CancellationToken ct) - { - _moves.Add(move); - return Task.CompletedTask; - } - - /// - public Task CountMovesAsync(CancellationToken ct) => Task.FromResult(_moves.Count); - - /// - public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) + private IReadOnlyList GetAiMarkupExamples(int limit) { var examples = new List(); for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--) @@ -429,31 +496,24 @@ public class FakeKanjStore : ICardStore examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol)); } - return Task.FromResult>(examples); + return examples; } - - /// - public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + private IReadOnlyList ListArchiveCandidates(DateTimeOffset receivedBeforeUtc) { var boardIds = new HashSet(_boards.Select(board => board.Id), StringComparer.Ordinal); - IReadOnlyList result = _cards + return _cards .Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col)) && ReceivedAtOf(card) < receivedBeforeUtc) .Select(card => card.Id) .ToList(); - return Task.FromResult(result); } - /// - public Task ArchiveAsync( - IReadOnlyList cardIds, - DateTimeOffset archivedAt, - CancellationToken ct) + private int Archive(IReadOnlyList cardIds, DateTimeOffset archivedAt) { if (cardIds.Count == 0) { - return Task.FromResult(0); + return 0; } var ids = new HashSet(cardIds, StringComparer.Ordinal); @@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore archived++; } - return Task.FromResult(archived); + return archived; } - /// - public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) + private IReadOnlyList ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc) { - IReadOnlyList result = _cards + return _cards .Where(card => card.Col == KanbanColumns.Archive && _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt) && archivedAt < archivedBeforeUtc) .Select(card => card.Id) .ToList(); - return Task.FromResult(result); } - /// - public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + private IReadOnlyList ListTrashCandidates(DateTimeOffset receivedBeforeUtc) { - IReadOnlyList result = _cards + return _cards .Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc) .Select(card => card.Id) .ToList(); - return Task.FromResult(result); } - /// - public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) + private int Purge(IReadOnlyList cardIds) { - // Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем. if (cardIds.Count == 0) { - return Task.FromResult(0); + return 0; } var ids = new HashSet(cardIds, StringComparer.Ordinal); @@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore _archivedAtById.Remove(cardId); } - return Task.FromResult(removed); + return removed; } - /// - /// Задаёт метку архивации карточки - /// - /// Id карточки. - /// Метка архивации (когда карточка ушла в архив). - public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt; - - /// - /// Метка архивации карточки — проверка archived_at после автоархива тика. - /// - /// Id карточки. - /// Метка архивации либо null — карточки нет/не архивирована/метка сброшена. - public DateTimeOffset? ArchivedAtOf(string cardId) + private IReadOnlyList ListCardsForConversion() { - return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null; - } - - // Момент получения карточки из её epoch-ms (как маппинг адаптера ReceivedAt → ReceivedAtMs). - // card: Карточка. - // Возвращает: ReceivedAt карточки как UTC-момент. - private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs); - - - /// - public Task> ListCardsForConversionAsync(CancellationToken ct) - { - IReadOnlyList result = _cards + return _cards .Where(card => card.Budget is not null && card.Col != KanbanColumns.Archive && card.Col != KanbanColumns.Trash) .OrderByDescending(card => card.ReceivedAtMs) .ToList(); - return Task.FromResult(result); } - /// - public Task UpdateConversionAsync( - string cardId, - double? convFrom, - double? convTo, - string convCur, - CancellationToken ct) + private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur) { int index = _cards.FindIndex(card => card.Id == cardId); if (index >= 0) { _cards[index] = _cards[index] with { - Converted = convCur.Length == 0 - ? null - : new CardBudgetDto(convFrom, convTo, convCur), + Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur), }; } - - return Task.CompletedTask; } - - - /// - public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) + private IReadOnlyList ListSelectedCards(string? containerId) { IEnumerable query = containerId is null ? _cards.Where(card => CardsDefaultContainers.Contains(card.Col)) : _cards.Where(card => card.Col == containerId); - return Task.FromResult>( - query.OrderByDescending(card => card.UpdatedAtMs).ToList()); + return query.OrderByDescending(card => card.UpdatedAtMs).ToList(); } - /// - public Task PatchCardAsync( - string cardId, - CardPatch patch, - CancellationToken ct) + private bool PatchCard(string cardId, CardPatch patch) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } CardDto card = _cards[index]; @@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore } _cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; - return Task.FromResult(true); + return true; } - /// - public Task AddLinkAsync( - string cardId, - CardLinkDto link, - CancellationToken ct) + private bool AddLink(string cardId, CardLinkDto link) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } _cards[index] = _cards[index] with @@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore Links = [.. _cards[index].Links, link], UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - return Task.FromResult(true); + return true; } - /// - public Task RemoveLinkAsync( - string cardId, - string linkId, - CancellationToken ct) + private bool RemoveLink(string cardId, string linkId) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } _cards[index] = _cards[index] with @@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - return Task.FromResult(true); + return true; } - /// - public Task AddFileAsync( - string cardId, - CardFileDto file, - CancellationToken ct) + private bool AddFile(string cardId, CardFileDto file) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } _cards[index] = _cards[index] with @@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore Files = [.. _cards[index].Files, file], UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - return Task.FromResult(true); + return true; } - /// - public Task RemoveFileAsync( - string cardId, - string fileId, - CancellationToken ct) + private bool RemoveFile(string cardId, string fileId) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } _cards[index] = _cards[index] with @@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - return Task.FromResult(true); + return true; } - /// - public Task MoveCardStageAsync( - string cardId, - string containerId, - CardHistoryDto historyEntry, - long atMs, - CancellationToken ct) + private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.FromResult(false); + return false; } CardDto card = _cards[index]; @@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore UpdatedAtMs = atMs, History = [.. card.History, historyEntry], }; - return Task.FromResult(true); + return true; } - /// - public Task SetReminderAsync( - string cardId, - long atMs, - CancellationToken ct) + private void SetReminder(string cardId, long atMs) { int index = _cards.FindIndex(card => card.Id == cardId); if (index < 0) { - return Task.CompletedTask; + return; } _firedById.Remove(cardId); @@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore Reminder = new CardReminderDto(atMs), UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - return Task.CompletedTask; } - /// - public Task ClearReminderAsync(string cardId, CancellationToken ct) + private void ClearReminder(string cardId) { int index = _cards.FindIndex(card => card.Id == cardId); if (index >= 0) @@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore } _firedById.Remove(cardId); - return Task.CompletedTask; } - /// - public Task ClearStageAsync(string containerId, CancellationToken ct) - { - return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId)); - } + private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId); - /// - public virtual Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) + private IReadOnlyList ListDueReminders(DateTimeOffset now) { long nowMs = now.ToUnixTimeMilliseconds(); - IReadOnlyList due = _cards + return _cards .Where(card => card.Col == CardsDefaultContainers.Hold && card.Reminder is not null && !_firedById.Contains(card.Id) @@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore .OrderBy(card => card.Reminder!.At) .Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col)) .ToList(); - return Task.FromResult(due); } - /// - public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct) + private void MarkRemindersFired(IReadOnlyList cardIds) { foreach (string cardId in cardIds) { @@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore _firedById.Add(cardId); } } - - return Task.CompletedTask; } - /// - public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct) + private int ClearExpiredReminders(DateTimeOffset now) { long nowMs = now.ToUnixTimeMilliseconds(); int cleared = 0; @@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore } } - return Task.FromResult(cleared); + return cleared; } - /// - public Task> ListInboxWithSourceAsync(CancellationToken ct) + private IReadOnlyList ListInboxWithSource() { - IReadOnlyList result = _cards + return _cards .Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty) .OrderByDescending(card => card.ReceivedAtMs) .ToList(); - return Task.FromResult(result); } + + private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs b/src/core/tests/Deal.Tests.Unit/Support/TestMlClient.cs similarity index 54% rename from src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs rename to src/core/tests/Deal.Tests.Unit/Support/TestMlClient.cs index 38dde69..15ccccb 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestMlClient.cs @@ -1,15 +1,22 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; +using NSubstitute; -namespace Deal.Tests.Unit.Contracts; +namespace Deal.Tests.Unit.Support; /// -/// In-memory реализация для unit-тестов CardsService и воркера pump. +/// Подставка : сервисы получают NSubstitute-подставку (), +/// тесты управляют сценариями через свойства (как раньше — через фейк). /// -public sealed class FakeMlClient : IMlClient +public sealed class TestMlClient { private readonly List<(string Text, string Label, double Delta)> _pushed = []; + /// + /// Подставка порта ML-клиента (создаётся в конструкторе). + /// + public IMlClient Client { get; } + /// /// Ответ StatusAsync /// @@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient public int PredictCalls { get; private set; } /// - /// Обучающие сигналы, отправленные через + /// Обучающие сигналы, отправленные через /// public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList(); - /// - public Task StatusAsync(CancellationToken ct) => Task.FromResult(Status); - - /// - public Task PushAsync( - string text, - string label, - double delta, - CancellationToken ct) + /// + /// Создаёт подставку с поведением по умолчанию. + /// + public TestMlClient() { - _pushed.Add((text, label, delta)); - return Task.CompletedTask; + Client = Substitute.For(); + Client.StatusAsync(Arg.Any()) + .Returns(ci => Status); + Client.When(c => c.PushAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _pushed.Add((ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2)))); + Client.PredictAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + PredictCalls++; + return Predict ?? throw new NotSupportedException(); + }); + Client.ResetAsync(Arg.Any()) + .Returns(_ => throw new NotSupportedException()); } - /// - public Task PredictAsync(string text, CancellationToken ct) - { - PredictCalls++; - return Predict is null - ? throw new NotSupportedException() - : Task.FromResult(Predict); - } - - /// - public Task ResetAsync(CancellationToken ct) => - throw new NotSupportedException(); - // Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto). // Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий. private static MlStatusResponseDto DefaultStatus() diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestMlLearningStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestMlLearningStore.cs new file mode 100644 index 0000000..aeebf7f --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestMlLearningStore.cs @@ -0,0 +1,65 @@ +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют очередь через и . +/// +public sealed class TestMlLearningStore +{ + private readonly List<(string Id, string Text, string Label, double Delta)> _rows = []; + + /// + /// Подставка порта ML-обучения (создаётся в конструкторе). + /// + public IMlLearningStore Store { get; } + + /// + /// Число записей журнала обучения + /// + public int LearningCount { get; set; } + + /// + /// Строки очереди + /// + public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList(); + + /// + /// Создаёт подставку с пустой очередью. + /// + public TestMlLearningStore() + { + Store = Substitute.For(); + Store.CountLearningAsync(Arg.Any()) + .Returns(ci => LearningCount); + Store.CountOutboxAsync(Arg.Any()) + .Returns(_ => _rows.Count); + Store.When(s => s.AddOutboxAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _rows.Add((ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3)))); + Store.When(s => s.ClearOutboxAsync(Arg.Any())) + .Do(_ => _rows.Clear()); + Store.TakeOutboxBatchAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _rows + .Take(ci.ArgAt(0)) + .Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta)) + .ToList()); + Store.When(s => s.DeleteOutboxAsync(Arg.Any>(), Arg.Any())) + .Do(ci => _rows.RemoveAll(row => ci.Arg>().Contains(row.Id))); + } + + /// + /// Пишет строку очереди напрямую + /// + /// Id строки (mle_...). + /// Текст обучающего примера. + /// Метка обучения. + /// Вес сигнала. + public void SeedOutbox(string id, string text, string label, double delta = 1.0) + { + _rows.Add((id, text, label, delta)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestOperatorAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestOperatorAuthStore.cs new file mode 100644 index 0000000..c70ed82 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestOperatorAuthStore.cs @@ -0,0 +1,88 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют операторов и сессии через +/// , , , , +/// журнал мутирующих вызовов — . +/// +public sealed class TestOperatorAuthStore +{ + private readonly List _operators = []; + private readonly List _sessions = []; + private readonly List _calls = []; + + /// + /// Подставка порта операторской аутентификации (создаётся в конструкторе). + /// + public IOperatorAuthStore Store { get; } + + /// + /// Операторы хранилища. + /// + public IReadOnlyList Operators => _operators; + + /// + /// Сессии операторов хранилища. + /// + public IReadOnlyList Sessions => _sessions; + + /// + /// Журнал мутирующих вызовов в порядке их совершения. + /// + public IReadOnlyList Calls => _calls; + + /// + /// Создаёт подставку с пустыми списками. + /// + public TestOperatorAuthStore() + { + Store = Substitute.For(); + Store.FindByLoginAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _operators.SingleOrDefault(o => o.Login == ci.ArgAt(0))); + Store.FindSessionByTokenHashAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt(0))); + Store.When(s => s.CreateAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + StoredOperatorDto operatorRecord = ci.Arg(); + _operators.Add(operatorRecord); + _calls.Add($"create-operator:{operatorRecord.Id}"); + }); + Store.When(s => s.CreateSessionAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + OperatorSessionDto session = ci.Arg(); + _sessions.Add(session); + _calls.Add($"create-session:{session.OperatorId}"); + }); + Store.When(s => s.DeleteSessionAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + _sessions.RemoveAll(s => s.TokenHash == ci.ArgAt(0)); + _calls.Add($"delete-session:{ci.ArgAt(0)}"); + }); + Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any())) + .Do(_ => + { + _sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow); + _calls.Add("delete-expired"); + }); + } + + /// + /// Добавляет оператора + /// + /// Оператор. + public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord); + + /// + /// Добавляет сессию + /// + /// Сессия. + public void AddSession(OperatorSessionDto session) => _sessions.Add(session); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs new file mode 100644 index 0000000..c169990 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs @@ -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; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют очередь и отсеянных через , +/// , , . +/// +public sealed class TestPipelineStore +{ + private readonly List _queue = []; + private readonly List _rejected = []; + private readonly Dictionary _dedup = new(StringComparer.Ordinal); + + /// + /// Подставка порта пайплайна (создаётся в конструкторе). + /// + public IPipelineStore Store { get; } + + /// + /// Строки очереди подставки + /// + public IReadOnlyList Queue => _queue.ToList(); + + /// + /// Записи отсева подставки + /// + public IReadOnlyList Rejected => _rejected.ToList(); + + private readonly bool _claimRace; + private readonly bool _claimResult; + private readonly bool _throwOnList; + + /// + /// Сколько раз вызван ClaimAsync + /// + public int ClaimCalls { get; private set; } + + /// + /// Сколько раз вызван DeleteClaimAsync + /// + public int DeleteClaimCalls { get; private set; } + + /// + /// Создаёт подставку с пустыми списками. + /// + /// Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult. + /// Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом). + /// Сценарий сбоя чтения очереди: ListAsync бросает (имитация недоступной схемы/БД). + public TestPipelineStore( + bool claimRace = false, + bool claimResult = false, + bool throwOnList = false) + { + _claimRace = claimRace; + _claimResult = claimResult; + _throwOnList = throwOnList; + Store = Substitute.For(); + Store.ExistsDuplicateAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ExistsDuplicate(ci.Arg())); + Store.When(s => s.AddAsync(Arg.Any(), Arg.Any())) + .Do(ci => _queue.Add(ci.Arg())); + if (_throwOnList) + { + Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).")); + } + else + { + Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => List(ci.ArgAt(0), ci.ArgAt(1))); + } + Store.CountByStatusAsync(Arg.Any(), Arg.Any()) + .Returns(ci => CountByStatus(ci.ArgAt(0))); + Store.When(s => s.SetStatusAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => SetStatus(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.RemoveAsync(Arg.Any(), Arg.Any())) + .Do(ci => Remove(ci.ArgAt(0))); + Store.When(s => s.UpsertAsync(Arg.Any(), Arg.Any())) + .Do(ci => Upsert(ci.Arg())); + Store.ListPageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ListPage(ci.ArgAt(0), ci.ArgAt(1))); + Store.SearchAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => Search(ci.ArgAt(0), ci.ArgAt(2))); + Store.CountAsync(Arg.Any()).Returns(_ => _rejected.Count); + Store.GetAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt(0))); + Store.When(s => s.DeleteAsync(Arg.Any(), Arg.Any())) + .Do(ci => _rejected.RemoveAll(item => item.Id == ci.ArgAt(0))); + Store.ClearAsync(Arg.Any()) + .Returns(ci => + { + int cleared = _rejected.Count; + _rejected.Clear(); + return cleared; + }); + Store.PurgeExpiredAsync(Arg.Any(), Arg.Any()) + .Returns(ci => PurgeExpired(ci.ArgAt(0))); + Store.When(s => s.MarkReturnedAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => MarkReturned(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.When(s => s.LinkAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _dedup[ci.ArgAt(0)] = ci.ArgAt(1)); + Store.When(s => s.DeleteByCardAsync(Arg.Any(), Arg.Any())) + .Do(ci => DeleteByCard(ci.ArgAt(0))); + Store.ExistsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => !_claimRace && _dedup.ContainsKey(ci.ArgAt(0))); + Store.When(s => s.ClaimAsync(Arg.Any(), Arg.Any())) + .Do(_ => ClaimCalls++); + Store.ClaimAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _claimRace + ? _claimResult + : _dedup.TryAdd(ci.ArgAt(0), null)); + Store.When(s => s.DeleteClaimAsync(Arg.Any(), Arg.Any())) + .Do(_ => DeleteClaimCalls++); + Store.When(s => s.DeleteClaimAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + if (!_claimRace && _dedup.TryGetValue(ci.ArgAt(0), out string? leadId) && leadId is null) + { + _dedup.Remove(ci.ArgAt(0)); + } + }); + } + + /// + /// Кладёт строку очереди напрямую + /// + /// Строка как если бы была сохранена в БД. + public void SeedQueue(QueueItemDto item) + { + _queue.Add(item); + } + + /// + /// Кладёт запись отсева напрямую + /// + /// Запись как если бы была сохранена в БД. + public void SeedRejected(RejectedItemDto item) + { + _rejected.Add(item); + } + + /// + /// Id карточки, связанной с хэшем дедупа + /// + /// SHA1-hex нормализованного текста. + 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 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 ListPage(int offset, int limit) + { + return _rejected + .OrderByDescending(item => item.RejectedAtMs) + .Skip(offset) + .Take(limit) + .ToList(); + } + + private IReadOnlyList 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, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestSettingsStore.cs new file mode 100644 index 0000000..c732c34 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestSettingsStore.cs @@ -0,0 +1,62 @@ +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на словаре: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют настройки через , +/// и . +/// +public sealed class TestSettingsStore +{ + private readonly Dictionary _rows = new(StringComparer.Ordinal); + + /// + /// Подставка порта настроек (создаётся в конструкторе). + /// + public ISettingsStore Store { get; } + + /// + /// Ключи сохранённых настроек. + /// + public IReadOnlyCollection Keys => _rows.Keys.ToList(); + + /// + /// Создаёт подставку с пустым словарём. + /// + public TestSettingsStore() + { + Store = Substitute.For(); + Store.GetAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _rows.TryGetValue(ci.ArgAt(0), out SettingValue? row) ? row : null); + Store.GetAllAsync(Arg.Any()) + .Returns(ci => _rows.Values.ToList()); + Store.When(s => s.SetAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _rows[ci.ArgAt(0)] = + new SettingValue(ci.ArgAt(0), ci.ArgAt(1), DateTimeOffset.UtcNow)); + Store.When(s => s.RemoveAsync(Arg.Any(), Arg.Any())) + .Do(ci => _rows.Remove(ci.ArgAt(0))); + } + + /// + /// Кладёт готовую строку + /// + /// Ключ настройки. + /// Значение, сериализованное в JSON. + public void Preload(string key, string valueJson) + { + _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); + } + + /// + /// Возвращает JSON сохранённого значения или null, если ключа нет. + /// + /// Ключ настройки. + /// JSON-строка значения или null. + public string? GetStoredJson(string key) + { + return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs index ab93c26..45cc407 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs @@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Support; /// public sealed class TokenUsageRecorderTests { - // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore. + // Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore. private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); @@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None); @@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_AccumulatesAcrossCalls() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None); @@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_NullUsage_IsNoop() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None); @@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. @@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddAsync_OutsideTenantContext_Throws() { - var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var settings = new TestSettingsStore(); + var limits = new TestTenantLimitStore(); ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. var recorder = new TokenUsageRecorder( - settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); + settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); await Assert.ThrowsAsync( () => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None)); @@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests [Fact] public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate() { - (FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); + (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); // 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются. long estimated = await recorder.AddEstimatedAsync( @@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Возвращает: Кортеж (настройки, лимиты, история, recorder). - private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() + private static (TestSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() { - var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); + var settings = new TestSettingsStore(); + var limits = new TestTenantLimitStore(); var events = new TestTokenUsageEventStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); - var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events.Store)); + var recorder = new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(events.Store)); return (settings, limits, events, recorder); } }