Перевести 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.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));
}
/// <summary>
@@ -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<IServiceScopeFactory>(),
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<IAuditLogStore>(_ => audit);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IRateLimitCounterStore>(_ => counters);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider();
}
@@ -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<DateTimeOffset> clock) =>
new(
new RateLimitOptions { Enabled = true },
new FakeRateLimitCounterStore(),
new TestRateLimitCounterStore().Store,
clock);
}
@@ -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);
@@ -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();
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(
@@ -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());
}
@@ -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<string, HttpClient, Task> scenario)
@@ -197,7 +197,7 @@ internal static class OperatorAuthHttpHost
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore);
builder.Services.AddSingleton<ITenantProvisioner, FakeTenantProvisioner>();
builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner);
builder.Services.AddScoped<TenantSchemaMigrationService>();
// Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте).
builder.Services.AddOptions<TenantCookieOptions>();
@@ -209,7 +209,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ServiceHealthProbe>();
builder.Services.AddSingleton<Deal.Api.Observability.RuntimeDepthsCollector>();
builder.Services.AddSingleton(effectiveRateLimitOptions);
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
@@ -161,7 +161,7 @@ public sealed class RateLimitHttpTests
if (options.Enabled)
{
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddDealRateLimiter(options);
}
@@ -88,7 +88,7 @@ internal static class TelegramIngressTestHost
});
if (rateLimitOptions is { Enabled: true })
{
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddSingleton(provider =>
IngressRateLimitInterceptor.CreateLimiter(
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);
}
});
}
}