Перевести FakeOperatorAuthStore на NSubstitute

Хелпер Support/TestOperatorAuthStore: операторы/сессии/журнал Calls, поиск и
мутации через Returns/When-Do. Потребители (14 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
2026-09-13 00:47:05 +03:00
parent 79b1893b28
commit a63bff7904
15 changed files with 138 additions and 50 deletions
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
var passwordHasher = TestHashers.New();
var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков.
var services = new ServiceCollection();
services.AddScoped<IOperatorAuthStore>(_ => store);
services.AddScoped<IOperatorAuthStore>(_ => store.Store);
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
services.AddScoped<OperatorBootstrapService>();
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
// Контекст теста bootstrap-шага.
private sealed record Context(
OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store,
TestOperatorAuthStore Store,
IPasswordHasher PasswordHasher,
ListLogger Logs);
@@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests
private static readonly Guid OperatorId = Guid.NewGuid();
private readonly FakeOperatorAuthStore _store;
private readonly TestOperatorAuthStore _store;
private readonly OperatorAuthService _service;
public OperatorAuthServiceTests()
{
var passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore();
_store = new TestOperatorAuthStore();
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
_service = new OperatorAuthService(_store, passwordHasher);
_service = new OperatorAuthService(_store.Store, passwordHasher);
}
[Fact]
@@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants;
/// </summary>
public sealed class OperatorBootstrapServiceTests
{
private readonly FakeOperatorAuthStore _store;
private readonly TestOperatorAuthStore _store;
private readonly IPasswordHasher _passwordHasher;
private readonly OperatorBootstrapService _service;
public OperatorBootstrapServiceTests()
{
_passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore();
_service = new OperatorBootstrapService(_store, _passwordHasher);
_store = new TestOperatorAuthStore();
_service = new OperatorBootstrapService(_store.Store, _passwordHasher);
}
[Fact]
@@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
{
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, users, _, _, _, _) =>
{
@@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
{
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, users, _, _, _, _) =>
{
@@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests
using var capture = new SecurityEventCapture();
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, _, _, _, _, _) =>
{
@@ -256,10 +256,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
return store;
@@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests
// ─── Хелперы ─────────────────────────────────────────────────────────
// Фейк-хранилище оператора с активным operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests
[Fact]
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
{
FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false);
TestOperatorAuthStore operatorStore = NewOperatorStore(active: false);
await OperatorAuthHttpHost.RunAsync(
operatorStore,
@@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests
}
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore(bool active = true)
private static TestOperatorAuthStore NewOperatorStore(bool active = true)
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -40,9 +40,9 @@ internal static class OperatorAuthHttpHost
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, Task> scenario,
TestAuditLogStore? auditStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -62,9 +62,9 @@ internal static class OperatorAuthHttpHost
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) =>
await RunCoreAsync(
@@ -86,9 +86,9 @@ internal static class OperatorAuthHttpHost
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) =>
@@ -113,9 +113,9 @@ internal static class OperatorAuthHttpHost
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null,
@@ -142,9 +142,9 @@ internal static class OperatorAuthHttpHost
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
public static async Task RunWithGlobalSettingsAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync(
@@ -159,13 +159,13 @@ internal static class OperatorAuthHttpHost
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
private static async Task RunCoreAsync(
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
FakeAuthStore userStore,
TestAuditLogStore? auditStore,
TestInviteStore? inviteStore,
TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null)
@@ -187,7 +187,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
@@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
{
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
@@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -257,7 +257,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{baseAddress}/api/operator/settings/telegram-keys";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(),
new FakeAuthStore(),
@@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -486,7 +486,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null)
{
@@ -504,7 +504,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
private static void AssertStatusChangedAudit(
TestAuditLogStore auditStore,
FakeOperatorAuthStore operatorStore,
TestOperatorAuthStore operatorStore,
string status)
{
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
@@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -0,0 +1,88 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IOperatorAuthStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют операторов и сессии через
/// <see cref="AddOperator"/>, <see cref="AddSession"/>, <see cref="Operators"/>, <see cref="Sessions"/>,
/// журнал мутирующих вызовов — <see cref="Calls"/>.
/// </summary>
public sealed class TestOperatorAuthStore
{
private readonly List<StoredOperatorDto> _operators = [];
private readonly List<OperatorSessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Подставка порта операторской аутентификации (создаётся в конструкторе).
/// </summary>
public IOperatorAuthStore Store { get; }
/// <summary>
/// Операторы хранилища.
/// </summary>
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
/// <summary>
/// Сессии операторов хранилища.
/// </summary>
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
public TestOperatorAuthStore()
{
Store = Substitute.For<IOperatorAuthStore>();
Store.FindByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _operators.SingleOrDefault(o => o.Login == ci.ArgAt<string>(0)));
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
Store.When(s => s.CreateAsync(Arg.Any<StoredOperatorDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
StoredOperatorDto operatorRecord = ci.Arg<StoredOperatorDto>();
_operators.Add(operatorRecord);
_calls.Add($"create-operator:{operatorRecord.Id}");
});
Store.When(s => s.CreateSessionAsync(Arg.Any<OperatorSessionDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
OperatorSessionDto session = ci.Arg<OperatorSessionDto>();
_sessions.Add(session);
_calls.Add($"create-session:{session.OperatorId}");
});
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
});
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
.Do(_ =>
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
});
}
/// <summary>
/// Добавляет оператора
/// </summary>
/// <param name="operatorRecord">Оператор.</param>
public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(OperatorSessionDto session) => _sessions.Add(session);
}