Перевести FakeTenantProvisioner, FailingTenantProvisioner и FakeRateLimitCounterStore на NSubstitute
ci / build-test (push) Canceled after 0s
ci / build-test (push) Canceled after 0s
Хелперы Support/TestTenantProvisioner (журнал схем + сценарий сбоя заданных схем, счётчик через When/Do) и Support/TestRateLimitCounterStore (словарь окон, Increment/GetCount/Reset/DeleteExpired читают состояние на момент вызова). Потребители (9 файлов) перетипизированы, фейк-классы удалены, тесты 1340.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+16
-15
@@ -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_<id>), провижининг которых бросает исключение.</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_<id>), провижининг которых бросает исключение.</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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user