diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index 54eae3f..9ba33ff 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -1,6 +1,7 @@ using Deal.Api.Configuration; using Deal.Api.Hosting; using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Tests.Unit.Support; using Deal.Modules.Tenants.Application.Models; using Deal.Tests.Unit.Modules.Tenants; using Microsoft.Extensions.DependencyInjection; @@ -31,9 +32,9 @@ public sealed class DataRetentionSchedulerTests // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); - var counters = new FakeRateLimitCounterStore(); - await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); - await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); + var counters = new TestRateLimitCounterStore(); + await counters.Store.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); + await counters.Store.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); await using ServiceProvider provider = BuildProvider(audit, limits, counters); DataRetentionScheduler scheduler = new( @@ -51,8 +52,8 @@ public sealed class DataRetentionSchedulerTests Assert.Equal(0, limits.UsedTokens(TenantId)); // Счётчики: завершившееся окно удалено, активное осталось. - Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); - Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); + Assert.Equal(0, await counters.Store.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); + Assert.Equal(1, await counters.Store.GetCountAsync("active", now, CancellationToken.None)); } /// @@ -66,7 +67,7 @@ public sealed class DataRetentionSchedulerTests await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); var limits = new FakeTenantLimitStore(); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); - await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); + await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); DataRetentionScheduler scheduler = new( provider.GetRequiredService(), new DataRetentionOptions { AuditRetentionDays = RetentionDays }, @@ -82,17 +83,17 @@ public sealed class DataRetentionSchedulerTests // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. // audit: Фейк-хранилище аудита (записи посеяны сценарием). // limits: Фейк-хранилище лимитов (строки посеяны сценарием). - // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). + // counters: Хелпер счётчиков (окна посеяны сценарием). // Возвращает: Провайдер с сервисами цикла. private static ServiceProvider BuildProvider( FakeAuditLogStore audit, FakeTenantLimitStore limits, - FakeRateLimitCounterStore counters) + TestRateLimitCounterStore counters) { var services = new ServiceCollection(); services.AddScoped(_ => audit); services.AddScoped(_ => limits); - services.AddScoped(_ => counters); + services.AddScoped(_ => counters.Store); return services.BuildServiceProvider(); } diff --git a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs index d8ef381..273c6e4 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs @@ -1,6 +1,7 @@ using Deal.Api.Configuration; using Deal.Api.Services; using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; @@ -165,7 +166,7 @@ public sealed class LoginAttemptGuardTests [Fact] public async Task DisabledByDefaultInDev_DoesNotCountOrBlock() { - var guard = new LoginAttemptGuard(new RateLimitOptions(), new FakeRateLimitCounterStore()); + var guard = new LoginAttemptGuard(new RateLimitOptions(), new TestRateLimitCounterStore().Store); for (int attempt = 0; attempt < 10; attempt++) { @@ -199,6 +200,6 @@ public sealed class LoginAttemptGuardTests private static LoginAttemptGuard NewGuard(Func clock) => new( new RateLimitOptions { Enabled = true }, - new FakeRateLimitCounterStore(), + new TestRateLimitCounterStore().Store, clock); } diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs index 4881ecf..a1144b7 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs @@ -1,5 +1,6 @@ using Deal.Infrastructure.Tenancy; using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Tests.Unit.Support; using Deal.Modules.Tenants.Application.Models; using Deal.Tests.Unit.Modules.Tenants; using Microsoft.Extensions.Logging.Abstractions; @@ -20,7 +21,7 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() { - var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); + var service = NewService(new FakeTenantRepository(), new TestTenantProvisioner().Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); @@ -33,11 +34,11 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() { - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var repository = new FakeTenantRepository( TenantRecord(TenantA, "A"), TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); @@ -54,11 +55,11 @@ public sealed class TenantSchemaMigrationServiceTests public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() { string failingSchema = $"tenant_{TenantA:N}"; - var provisioner = new FailingTenantProvisioner(failingSchema); + var provisioner = new TestTenantProvisioner(failingSchema); var repository = new FakeTenantRepository( TenantRecord(TenantA, "A"), TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); @@ -74,9 +75,9 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant() { - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var repository = new FakeTenantRepository(Records(5)); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None); @@ -95,9 +96,9 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant() { - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var repository = new FakeTenantRepository(Records(4)); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None); @@ -116,9 +117,9 @@ public sealed class TenantSchemaMigrationServiceTests { TenantRecordDto[] records = Records(3); string failingSchema = $"tenant_{records[0].Id:N}"; - var provisioner = new FailingTenantProvisioner(failingSchema); + var provisioner = new TestTenantProvisioner(failingSchema); var repository = new FakeTenantRepository(records); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None); @@ -134,9 +135,9 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum() { - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var repository = new FakeTenantRepository(Records(3)); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None); @@ -150,11 +151,11 @@ public sealed class TenantSchemaMigrationServiceTests [Fact] public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant() { - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var repository = new FakeTenantRepository( TenantRecord(TenantA, "A"), TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); + var service = NewService(repository, provisioner.Provisioner); TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs deleted file mode 100644 index ba12abf..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для тестов пакетной миграции -/// -public sealed class FailingTenantProvisioner : ITenantProvisioner -{ - private readonly HashSet _failingSchemaNames; - private readonly List _provisionedSchemaNames = []; - private readonly object _gate = new(); - - /// - /// Создаёт провижинер, роняющий провижининг указанных схем. - /// - /// Имена схем (tenant_<id>), провижининг которых бросает исключение. - public FailingTenantProvisioner(params string[] failingSchemaNames) - { - _failingSchemaNames = new HashSet(failingSchemaNames, StringComparer.Ordinal); - } - - /// - /// Имена успешно провижиненных схем в порядке вызовов. - /// - public IReadOnlyList ProvisionedSchemaNames - { - get - { - lock (_gate) - { - return _provisionedSchemaNames.ToArray(); - } - } - } - - /// - public Task ProvisionAsync(TenantId tenantId, CancellationToken ct) - { - if (_failingSchemaNames.Contains(tenantId.SchemaName)) - { - throw new InvalidOperationException($"Тест: схема {tenantId.SchemaName} недоступна"); - } - - lock (_gate) - { - _provisionedSchemaNames.Add(tenantId.SchemaName); - } - - return Task.CompletedTask; - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs deleted file mode 100644 index a7baf02..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для юнит/HTTP-тестов -/// -public sealed class FakeRateLimitCounterStore : IRateLimitCounterStore -{ - private sealed class Row - { - public DateTimeOffset WindowStart { get; set; } - - public DateTimeOffset ExpiresAt { get; set; } - - public int Count { get; set; } - } - - private readonly Dictionary _rows = new(); - - /// - public Task IncrementAsync( - string key, - DateTimeOffset windowStart, - DateTimeOffset windowEnd, - int amount, - CancellationToken ct) - { - if (!_rows.TryGetValue(key, out Row? row) || row.WindowStart != windowStart) - { - row = new Row { WindowStart = windowStart, ExpiresAt = windowEnd, Count = amount }; - _rows[key] = row; - } - else - { - row.Count += amount; - row.ExpiresAt = windowEnd; - } - - return Task.FromResult(row.Count); - } - - /// - public Task GetCountAsync( - string key, - DateTimeOffset windowStart, - CancellationToken ct) - => Task.FromResult(_rows.TryGetValue(key, out Row? row) && row.WindowStart == windowStart ? row.Count : 0); - - /// - public Task ResetAsync(string key, CancellationToken ct) - { - _rows.Remove(key); - return Task.CompletedTask; - } - - /// - public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct) - { - List expired = _rows.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList(); - foreach (string key in expired) - { - _rows.Remove(key); - } - - return Task.FromResult(expired.Count); - } - - /// - /// Число заведённых счётчиков - /// - public int Count => _rows.Count; -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs deleted file mode 100644 index d064d27..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для unit/HTTP-тестов join-потока -/// -public sealed class FakeTenantProvisioner : ITenantProvisioner -{ - private readonly List _provisionedSchemaNames = []; - private readonly object _gate = new(); - - /// - /// Имена провижиненных схем - /// - public IReadOnlyList ProvisionedSchemaNames - { - get - { - lock (_gate) - { - return _provisionedSchemaNames.ToArray(); - } - } - } - - /// - public Task ProvisionAsync(TenantId tenantId, CancellationToken ct) - { - lock (_gate) - { - _provisionedSchemaNames.Add(tenantId.SchemaName); - } - - return Task.CompletedTask; - } -} 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 32a31b4..848f194 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs @@ -27,11 +27,11 @@ public sealed class JoinFlowTests { var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); var passwordHasher = TestHashers.New(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, passwordHasher); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher); // Email с регистром/пробелами нормализуется и совпадает с инвайтом; name задаёт имя нового тенанта. var result = await service.ActivateAsync(Code, " NEW-USER@Example.COM ", "Acme", Password, CancellationToken.None); @@ -70,7 +70,7 @@ public sealed class JoinFlowTests var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new FakeTenantStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None); @@ -84,11 +84,11 @@ public sealed class JoinFlowTests var tenantId = Guid.NewGuid(); var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -109,7 +109,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -127,7 +127,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -143,7 +143,7 @@ public sealed class JoinFlowTests var inviteStore = new FakeInviteStore(); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None); @@ -160,7 +160,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -176,7 +176,7 @@ public sealed class JoinFlowTests { var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); - var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), 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 FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); - var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New()); + var service = NewService(inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -204,7 +204,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None); @@ -223,8 +223,8 @@ public sealed class JoinFlowTests var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash")); - var provisioner = new FakeTenantProvisioner(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New()); + var provisioner = new TestTenantProvisioner(); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -249,7 +249,7 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None); @@ -265,10 +265,10 @@ public sealed class JoinFlowTests { var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New()); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); JoinResultDto second = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -288,8 +288,8 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var provisioner = new FakeTenantProvisioner(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New()); + var provisioner = new TestTenantProvisioner(); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -310,8 +310,8 @@ public sealed class JoinFlowTests inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); - var provisioner = new FakeTenantProvisioner(); - var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New()); + var provisioner = new TestTenantProvisioner(); + var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); @@ -355,7 +355,7 @@ public sealed class JoinFlowTests private static JoinService NewService( FakeInviteStore inviteStore, FakeTenantStore tenantStore, - FakeTenantProvisioner provisioner, + ITenantProvisioner provisioner, FakeAuthStore authStore, IPasswordHasher passwordHasher) => new( 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 69e1590..3259d39 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs @@ -17,7 +17,7 @@ public sealed class TenantAdminServiceTests public async Task CreateAsync_WithName_CreatesTenantAndProvisionsSchema() { var tenantStore = new FakeTenantStore(); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var service = NewService(tenantStore, new FakeAuthStore(), provisioner); TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None); @@ -44,7 +44,7 @@ public sealed class TenantAdminServiceTests var tenantStore = new FakeTenantStore(); var authStore = new FakeAuthStore(); var hasher = TestHashers.New(); - var service = NewService(tenantStore, authStore, new FakeTenantProvisioner(), hasher); + var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher); TenantCreateResultDto result = await service.CreateAsync("Тенант с владельцем", " Owner@Example.COM ", CancellationToken.None); @@ -200,10 +200,10 @@ public sealed class TenantAdminServiceTests private static TenantAdminService NewService( FakeTenantStore tenantStore, FakeAuthStore authStore, - FakeTenantProvisioner? provisioner = null, + TestTenantProvisioner? provisioner = null, IPasswordHasher? hasher = null) { - var tenantService = new TenantService(tenantStore, provisioner ?? new FakeTenantProvisioner()); + var tenantService = new TenantService(tenantStore, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner); return new TenantAdminService(tenantStore, authStore, tenantService, hasher ?? TestHashers.New()); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs index 9fed20c..94b5b55 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs @@ -39,11 +39,11 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email)); var authStore = new FakeAuthStore(); var tenantStore = new FakeTenantStore(); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var auditStore = new FakeAuditLogStore(); await RunAsync( - inviteStore, tenantStore, provisioner, authStore, auditStore, + inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore, async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -83,7 +83,7 @@ public sealed class JoinEndpointHttpTests var authStore = new FakeAuthStore(); await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), async (baseAddress, client) => { using (HttpResponseMessage first = await PostJsonAsync( @@ -111,7 +111,7 @@ public sealed class JoinEndpointHttpTests var authStore = new FakeAuthStore(); await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), 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 FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), 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 FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), 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 FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + new FakeInviteStore(), new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email)); await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -205,7 +205,7 @@ public sealed class JoinEndpointHttpTests authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -227,11 +227,11 @@ public sealed class JoinEndpointHttpTests inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var provisioner = new FakeTenantProvisioner(); + var provisioner = new TestTenantProvisioner(); var authStore = new FakeAuthStore(); await RunAsync( - inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), + inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(), async (baseAddress, client) => { using HttpResponseMessage response = await PostJsonAsync( @@ -251,7 +251,7 @@ public sealed class JoinEndpointHttpTests private static async Task RunAsync( FakeInviteStore inviteStore, FakeTenantStore tenantStore, - FakeTenantProvisioner provisioner, + ITenantProvisioner provisioner, FakeAuthStore authStore, FakeAuditLogStore auditStore, Func scenario) diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 52007b7..3c3932f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -197,7 +197,7 @@ internal static class OperatorAuthHttpHost // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). builder.Services.AddScoped(); builder.Services.AddSingleton(effectiveTokenUsageStore); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner); builder.Services.AddScoped(); // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). builder.Services.AddOptions(); @@ -209,7 +209,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(effectiveRateLimitOptions); - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddSingleton(new TestRateLimitCounterStore().Store); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs index 7fcef96..d9e7e8c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs @@ -161,7 +161,7 @@ public sealed class RateLimitHttpTests if (options.Enabled) { - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddSingleton(new TestRateLimitCounterStore().Store); builder.Services.AddDealRateLimiter(options); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs index ad47b31..f2efa76 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -88,7 +88,7 @@ internal static class TelegramIngressTestHost }); if (rateLimitOptions is { Enabled: true }) { - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddSingleton(new TestRateLimitCounterStore().Store); builder.Services.AddSingleton(provider => IngressRateLimitInterceptor.CreateLimiter( provider.GetRequiredService(), diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestRateLimitCounterStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestRateLimitCounterStore.cs new file mode 100644 index 0000000..1523b93 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestRateLimitCounterStore.cs @@ -0,0 +1,73 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на словаре: сервисы получают NSubstitute-подставку +/// (), окно счётчика сбрасывается при смене начала периода. +/// +public sealed class TestRateLimitCounterStore +{ + private sealed class Row + { + public DateTimeOffset WindowStart { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public int Count { get; set; } + } + + private readonly Dictionary _rows = new(); + + /// + /// Подставка порта счётчиков (создаётся в конструкторе). + /// + public IRateLimitCounterStore Store { get; } + + /// + /// Создаёт подставку с пустым словарём. + /// + public TestRateLimitCounterStore() + { + Store = Substitute.For(); + Store.IncrementAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => Increment(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2), ci.ArgAt(3))); + Store.GetCountAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => GetCount(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.ResetAsync(Arg.Any(), Arg.Any())) + .Do(ci => _rows.Remove(ci.ArgAt(0))); + Store.DeleteExpiredAsync(Arg.Any(), Arg.Any()) + .Returns(ci => DeleteExpired(ci.ArgAt(0))); + } + + private int Increment(string key, DateTimeOffset windowStart, DateTimeOffset windowEnd, int amount) + { + if (!_rows.TryGetValue(key, out Row? row) || row.WindowStart != windowStart) + { + row = new Row { WindowStart = windowStart, ExpiresAt = windowEnd, Count = amount }; + _rows[key] = row; + } + else + { + row.Count += amount; + row.ExpiresAt = windowEnd; + } + + return row.Count; + } + + private int GetCount(string key, DateTimeOffset windowStart) + => _rows.TryGetValue(key, out Row? row) && row.WindowStart == windowStart ? row.Count : 0; + + private int DeleteExpired(DateTimeOffset now) + { + List expired = _rows.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList(); + foreach (string key in expired) + { + _rows.Remove(key); + } + + return expired.Count; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/Support/TestTenantProvisioner.cs new file mode 100644 index 0000000..0beb90e --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestTenantProvisioner.cs @@ -0,0 +1,59 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка с журналом провижиненных схем: сервис получает +/// NSubstitute-подставку (), тесты проверяют журнал через свойства. +/// +public sealed class TestTenantProvisioner +{ + private readonly List _provisionedSchemaNames = []; + private readonly HashSet _failingSchemaNames; + private readonly object _gate = new(); + + /// + /// Подставка порта провижининга (создаётся в конструкторе). + /// + public ITenantProvisioner Provisioner { get; } + + /// + /// Имена провижиненных схем + /// + public IReadOnlyList ProvisionedSchemaNames + { + get + { + lock (_gate) + { + return _provisionedSchemaNames.ToArray(); + } + } + } + + /// + /// Создаёт подставку, записывающую схему каждого вызова. + /// + /// Имена схем (tenant_<id>), провижининг которых бросает исключение. + public TestTenantProvisioner(params string[] failingSchemaNames) + { + _failingSchemaNames = new HashSet(failingSchemaNames, StringComparer.Ordinal); + Provisioner = Substitute.For(); + Provisioner.When(p => p.ProvisionAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + string schemaName = ci.Arg().SchemaName; + lock (_gate) + { + if (_failingSchemaNames.Contains(schemaName)) + { + throw new InvalidOperationException($"Тестовый сбой провижининга схемы {schemaName}."); + } + + _provisionedSchemaNames.Add(schemaName); + } + }); + } +}