Перевести FakeTenantProvisioner, FailingTenantProvisioner и FakeRateLimitCounterStore на NSubstitute
ci / build-test (push) Canceled after 0s

Хелперы Support/TestTenantProvisioner (журнал схем + сценарий сбоя заданных схем,
счётчик через When/Do) и Support/TestRateLimitCounterStore (словарь окон,
Increment/GetCount/Reset/DeleteExpired читают состояние на момент вызова).
Потребители (9 файлов) перетипизированы, фейк-классы удалены, тесты 1340.
This commit is contained in:
Rustam Khalimov
2026-09-12 20:41:24 +03:00
parent b8c3f3e9aa
commit 9fd0df3c95
14 changed files with 203 additions and 232 deletions
@@ -1,6 +1,7 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Api.Hosting; using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection; 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); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true);
var counters = new FakeRateLimitCounterStore(); var counters = new TestRateLimitCounterStore();
await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); await counters.Store.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None);
await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); await counters.Store.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None);
await using ServiceProvider provider = BuildProvider(audit, limits, counters); await using ServiceProvider provider = BuildProvider(audit, limits, counters);
DataRetentionScheduler scheduler = new( DataRetentionScheduler scheduler = new(
@@ -51,8 +52,8 @@ public sealed class DataRetentionSchedulerTests
Assert.Equal(0, limits.UsedTokens(TenantId)); Assert.Equal(0, limits.UsedTokens(TenantId));
// Счётчики: завершившееся окно удалено, активное осталось. // Счётчики: завершившееся окно удалено, активное осталось.
Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); Assert.Equal(0, await counters.Store.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None));
Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); Assert.Equal(1, await counters.Store.GetCountAsync("active", now, CancellationToken.None));
} }
/// <summary> /// <summary>
@@ -66,7 +67,7 @@ public sealed class DataRetentionSchedulerTests
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
DataRetentionScheduler scheduler = new( DataRetentionScheduler scheduler = new(
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
new DataRetentionOptions { AuditRetentionDays = RetentionDays }, new DataRetentionOptions { AuditRetentionDays = RetentionDays },
@@ -82,17 +83,17 @@ public sealed class DataRetentionSchedulerTests
// Строит DI-провайдер теста: три фейк-хранилища в scope прохода. // Строит DI-провайдер теста: три фейк-хранилища в scope прохода.
// audit: Фейк-хранилище аудита (записи посеяны сценарием). // audit: Фейк-хранилище аудита (записи посеяны сценарием).
// limits: Фейк-хранилище лимитов (строки посеяны сценарием). // limits: Фейк-хранилище лимитов (строки посеяны сценарием).
// counters: Фейк-хранилище счётчиков (окна посеяны сценарием). // counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
FakeAuditLogStore audit, FakeAuditLogStore audit,
FakeTenantLimitStore limits, FakeTenantLimitStore limits,
FakeRateLimitCounterStore counters) TestRateLimitCounterStore counters)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit); services.AddScoped<IAuditLogStore>(_ => audit);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IRateLimitCounterStore>(_ => counters); services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
@@ -1,6 +1,7 @@
using Deal.Api.Configuration; using Deal.Api.Configuration;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api; namespace Deal.Tests.Unit.Api;
@@ -165,7 +166,7 @@ public sealed class LoginAttemptGuardTests
[Fact] [Fact]
public async Task DisabledByDefaultInDev_DoesNotCountOrBlock() 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++) for (int attempt = 0; attempt < 10; attempt++)
{ {
@@ -199,6 +200,6 @@ public sealed class LoginAttemptGuardTests
private static LoginAttemptGuard NewGuard(Func<DateTimeOffset> clock) => private static LoginAttemptGuard NewGuard(Func<DateTimeOffset> clock) =>
new( new(
new RateLimitOptions { Enabled = true }, new RateLimitOptions { Enabled = true },
new FakeRateLimitCounterStore(), new TestRateLimitCounterStore().Store,
clock); clock);
} }
@@ -1,5 +1,6 @@
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -20,7 +21,7 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() 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); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -33,11 +34,11 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -54,11 +55,11 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed()
{ {
string failingSchema = $"tenant_{TenantA:N}"; string failingSchema = $"tenant_{TenantA:N}";
var provisioner = new FailingTenantProvisioner(failingSchema); var provisioner = new TestTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -74,9 +75,9 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant() public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(5)); 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); TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
@@ -95,9 +96,9 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant() public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(4)); 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); TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
@@ -116,9 +117,9 @@ public sealed class TenantSchemaMigrationServiceTests
{ {
TenantRecordDto[] records = Records(3); TenantRecordDto[] records = Records(3);
string failingSchema = $"tenant_{records[0].Id:N}"; string failingSchema = $"tenant_{records[0].Id:N}";
var provisioner = new FailingTenantProvisioner(failingSchema); var provisioner = new TestTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(records); 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); TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
@@ -134,9 +135,9 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum() public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(3)); 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); TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
@@ -150,11 +151,11 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact] [Fact]
public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant() public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
{ {
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository( var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"), TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B")); TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner); var service = NewService(repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None); TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
@@ -1,53 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantProvisioner"/> для тестов пакетной миграции
/// </summary>
public sealed class FailingTenantProvisioner : ITenantProvisioner
{
private readonly HashSet<string> _failingSchemaNames;
private readonly List<string> _provisionedSchemaNames = [];
private readonly object _gate = new();
/// <summary>
/// Создаёт провижинер, роняющий провижининг указанных схем.
/// </summary>
/// <param name="failingSchemaNames">Имена схем (tenant_&lt;id&gt), провижининг которых бросает исключение.</param>
public FailingTenantProvisioner(params string[] failingSchemaNames)
{
_failingSchemaNames = new HashSet<string>(failingSchemaNames, StringComparer.Ordinal);
}
/// <summary>
/// Имена успешно провижиненных схем в порядке вызовов.
/// </summary>
public IReadOnlyList<string> ProvisionedSchemaNames
{
get
{
lock (_gate)
{
return _provisionedSchemaNames.ToArray();
}
}
}
/// <inheritdoc />
public Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
{
if (_failingSchemaNames.Contains(tenantId.SchemaName))
{
throw new InvalidOperationException($"Тест: схема {tenantId.SchemaName} недоступна");
}
lock (_gate)
{
_provisionedSchemaNames.Add(tenantId.SchemaName);
}
return Task.CompletedTask;
}
}
@@ -1,73 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IRateLimitCounterStore"/> для юнит/HTTP-тестов
/// </summary>
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<string, Row> _rows = new();
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<int> GetCountAsync(
string key,
DateTimeOffset windowStart,
CancellationToken ct)
=> Task.FromResult(_rows.TryGetValue(key, out Row? row) && row.WindowStart == windowStart ? row.Count : 0);
/// <inheritdoc />
public Task ResetAsync(string key, CancellationToken ct)
{
_rows.Remove(key);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct)
{
List<string> 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);
}
/// <summary>
/// Число заведённых счётчиков
/// </summary>
public int Count => _rows.Count;
}
@@ -1,38 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantProvisioner"/> для unit/HTTP-тестов join-потока
/// </summary>
public sealed class FakeTenantProvisioner : ITenantProvisioner
{
private readonly List<string> _provisionedSchemaNames = [];
private readonly object _gate = new();
/// <summary>
/// Имена провижиненных схем
/// </summary>
public IReadOnlyList<string> ProvisionedSchemaNames
{
get
{
lock (_gate)
{
return _provisionedSchemaNames.ToArray();
}
}
}
/// <inheritdoc />
public Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
{
lock (_gate)
{
_provisionedSchemaNames.Add(tenantId.SchemaName);
}
return Task.CompletedTask;
}
}
@@ -27,11 +27,11 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, passwordHasher); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
// Email с регистром/пробелами нормализуется и совпадает с инвайтом; name задаёт имя нового тенанта. // Email с регистром/пробелами нормализуется и совпадает с инвайтом; name задаёт имя нового тенанта.
var result = await service.ActivateAsync(Code, " NEW-USER@Example.COM ", "Acme", Password, CancellationToken.None); 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(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); 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); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -84,11 +84,11 @@ public sealed class JoinFlowTests
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); 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); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore(); 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); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); 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); 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 inviteStore = new FakeInviteStore();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); 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); 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))); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); 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); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var service = NewService(inviteStore, new 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); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var service = NewService(inviteStore, new 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); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); 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); 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 tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
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); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); 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); var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
@@ -265,10 +265,10 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); 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 first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
JoinResultDto second = 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
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); 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)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
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); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -355,7 +355,7 @@ public sealed class JoinFlowTests
private static JoinService NewService( private static JoinService NewService(
FakeInviteStore inviteStore, FakeInviteStore inviteStore,
FakeTenantStore tenantStore, FakeTenantStore tenantStore,
FakeTenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, FakeAuthStore authStore,
IPasswordHasher passwordHasher) => IPasswordHasher passwordHasher) =>
new( new(
@@ -17,7 +17,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithName_CreatesTenantAndProvisionsSchema() public async Task CreateAsync_WithName_CreatesTenantAndProvisionsSchema()
{ {
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(tenantStore, new FakeAuthStore(), provisioner); var service = NewService(tenantStore, new FakeAuthStore(), provisioner);
TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None); TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
@@ -44,7 +44,7 @@ public sealed class TenantAdminServiceTests
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var hasher = TestHashers.New(); 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); TenantCreateResultDto result = await service.CreateAsync("Тенант с владельцем", " Owner@Example.COM ", CancellationToken.None);
@@ -200,10 +200,10 @@ public sealed class TenantAdminServiceTests
private static TenantAdminService NewService( private static TenantAdminService NewService(
FakeTenantStore tenantStore, FakeTenantStore tenantStore,
FakeAuthStore authStore, FakeAuthStore authStore,
FakeTenantProvisioner? provisioner = null, TestTenantProvisioner? provisioner = null,
IPasswordHasher? hasher = 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()); return new TenantAdminService(tenantStore, authStore, tenantService, hasher ?? TestHashers.New());
} }
@@ -39,11 +39,11 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore(); var auditStore = new FakeAuditLogStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner, authStore, auditStore, inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -83,7 +83,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using (HttpResponseMessage first = await PostJsonAsync( using (HttpResponseMessage first = await PostJsonAsync(
@@ -111,7 +111,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound() public async Task Join_WithUnknownCode_Returns400NotFound()
{ {
await RunAsync( await RunAsync(
new FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), new FakeInviteStore(), new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( 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")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
await RunAsync( await RunAsync(
inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -227,11 +227,11 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId));
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var provisioner = new FakeTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -251,7 +251,7 @@ public sealed class JoinEndpointHttpTests
private static async Task RunAsync( private static async Task RunAsync(
FakeInviteStore inviteStore, FakeInviteStore inviteStore,
FakeTenantStore tenantStore, FakeTenantStore tenantStore,
FakeTenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, FakeAuthStore authStore,
FakeAuditLogStore auditStore, FakeAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario) Func<string, HttpClient, Task> scenario)
@@ -197,7 +197,7 @@ internal static class OperatorAuthHttpHost
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>(); builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore); builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore);
builder.Services.AddSingleton<ITenantProvisioner, FakeTenantProvisioner>(); builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>(); builder.Services.AddScoped<TenantSchemaMigrationService>();
// Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте).
builder.Services.AddOptions<TenantCookieOptions>(); builder.Services.AddOptions<TenantCookieOptions>();
@@ -209,7 +209,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ServiceHealthProbe>(); builder.Services.AddSingleton<ServiceHealthProbe>();
builder.Services.AddSingleton<Deal.Api.Observability.RuntimeDepthsCollector>(); builder.Services.AddSingleton<Deal.Api.Observability.RuntimeDepthsCollector>();
builder.Services.AddSingleton(effectiveRateLimitOptions); builder.Services.AddSingleton(effectiveRateLimitOptions);
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore()); builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>(); builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>(); builder.Services.AddScoped<SuspiciousActivityReporter>();
@@ -161,7 +161,7 @@ public sealed class RateLimitHttpTests
if (options.Enabled) if (options.Enabled)
{ {
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore()); builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddDealRateLimiter(options); builder.Services.AddDealRateLimiter(options);
} }
@@ -88,7 +88,7 @@ internal static class TelegramIngressTestHost
}); });
if (rateLimitOptions is { Enabled: true }) if (rateLimitOptions is { Enabled: true })
{ {
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore()); builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddSingleton(provider => builder.Services.AddSingleton(provider =>
IngressRateLimitInterceptor.CreateLimiter( IngressRateLimitInterceptor.CreateLimiter(
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
@@ -0,0 +1,73 @@
using Deal.Modules.Tenants.Application.Abstractions;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IRateLimitCounterStore"/> на словаре: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), окно счётчика сбрасывается при смене начала периода.
/// </summary>
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<string, Row> _rows = new();
/// <summary>
/// Подставка порта счётчиков (создаётся в конструкторе).
/// </summary>
public IRateLimitCounterStore Store { get; }
/// <summary>
/// Создаёт подставку с пустым словарём.
/// </summary>
public TestRateLimitCounterStore()
{
Store = Substitute.For<IRateLimitCounterStore>();
Store.IncrementAsync(Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => Increment(ci.ArgAt<string>(0), ci.ArgAt<DateTimeOffset>(1), ci.ArgAt<DateTimeOffset>(2), ci.ArgAt<int>(3)));
Store.GetCountAsync(Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => GetCount(ci.ArgAt<string>(0), ci.ArgAt<DateTimeOffset>(1)));
Store.When(s => s.ResetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows.Remove(ci.ArgAt<string>(0)));
Store.DeleteExpiredAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => DeleteExpired(ci.ArgAt<DateTimeOffset>(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<string> expired = _rows.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList();
foreach (string key in expired)
{
_rows.Remove(key);
}
return expired.Count;
}
}
@@ -0,0 +1,59 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITenantProvisioner"/> с журналом провижиненных схем: сервис получает
/// NSubstitute-подставку (<see cref="Provisioner"/>), тесты проверяют журнал через свойства.
/// </summary>
public sealed class TestTenantProvisioner
{
private readonly List<string> _provisionedSchemaNames = [];
private readonly HashSet<string> _failingSchemaNames;
private readonly object _gate = new();
/// <summary>
/// Подставка порта провижининга (создаётся в конструкторе).
/// </summary>
public ITenantProvisioner Provisioner { get; }
/// <summary>
/// Имена провижиненных схем
/// </summary>
public IReadOnlyList<string> ProvisionedSchemaNames
{
get
{
lock (_gate)
{
return _provisionedSchemaNames.ToArray();
}
}
}
/// <summary>
/// Создаёт подставку, записывающую схему каждого вызова.
/// </summary>
/// <param name="failingSchemaNames">Имена схем (tenant_&lt;id&gt;), провижининг которых бросает исключение.</param>
public TestTenantProvisioner(params string[] failingSchemaNames)
{
_failingSchemaNames = new HashSet<string>(failingSchemaNames, StringComparer.Ordinal);
Provisioner = Substitute.For<ITenantProvisioner>();
Provisioner.When(p => p.ProvisionAsync(Arg.Any<TenantId>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
string schemaName = ci.Arg<TenantId>().SchemaName;
lock (_gate)
{
if (_failingSchemaNames.Contains(schemaName))
{
throw new InvalidOperationException($"Тестовый сбой провижининга схемы {schemaName}.");
}
_provisionedSchemaNames.Add(schemaName);
}
});
}
}