Перевести FakeTenantStore на NSubstitute
ci / build-test (push) Successful in 2m54s

Хелпер Support/TestTenantStore: реестр на списке с Create/UpdateStatus,
DI получает .Repository. Потребители (10 файлов), фейк удалён,
тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 22:44:59 +03:00
parent 3fb63489d6
commit 1db703c93b
11 changed files with 137 additions and 76 deletions
@@ -18,16 +18,16 @@ public sealed class AuthServiceTests
private static readonly Guid UserTenantId = Guid.NewGuid();
private readonly FakeAuthStore _store;
private readonly FakeTenantStore _tenantStore;
private readonly TestTenantStore _tenantStore;
private readonly AuthService _service;
public AuthServiceTests()
{
var passwordHasher = TestHashers.New();
_store = new FakeAuthStore();
_tenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
_service = new AuthService(_store, passwordHasher, _tenantStore);
_service = new AuthService(_store, passwordHasher, _tenantStore.Repository);
}
[Fact]
@@ -161,12 +161,12 @@ public sealed class AuthServiceTests
string rawToken = "suspended-tenant-session";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None);
await _tenantStore.Repository.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None);
Assert.Null(await _service.ResolveSessionAsync(rawToken, CancellationToken.None));
// Строка сессии не удалена: resume возвращает доступ тем же токеном.
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Active, CancellationToken.None);
await _tenantStore.Repository.UpdateStatusAsync(UserTenantId, TenantStatuses.Active, CancellationToken.None);
var resumed = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.NotNull(resumed);
Assert.Equal(UserId, resumed!.Id);
@@ -206,8 +206,8 @@ public sealed class AuthServiceTests
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore);
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
@@ -227,8 +227,8 @@ public sealed class AuthServiceTests
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore);
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
@@ -271,7 +271,7 @@ public sealed class AuthServiceTests
var secondUserId = Guid.NewGuid();
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1")));
multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2")));
var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore);
var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore.Repository);
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
@@ -296,7 +296,7 @@ public sealed class AuthServiceTests
var passwordHasher = TestHashers.New();
var crossStore = new FakeAuthStore();
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore);
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore.Repository);
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(cross.Ok);
@@ -318,7 +318,7 @@ public sealed class AuthServiceTests
public async Task ImpersonateAsync_ForTenantWithoutUsers_ReturnsTenantHasNoUsers()
{
var emptyTenantId = Guid.NewGuid();
await _tenantStore.CreateAsync(new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow), CancellationToken.None);
await _tenantStore.Repository.CreateAsync(new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow), CancellationToken.None);
var result = await _service.ImpersonateAsync(emptyTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
@@ -28,7 +28,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
@@ -69,7 +69,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -86,7 +86,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -107,7 +107,7 @@ public sealed class JoinFlowTests
var tenantId = Guid.NewGuid();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -125,7 +125,7 @@ public sealed class JoinFlowTests
var tenantId = Guid.NewGuid();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -141,7 +141,7 @@ public sealed class JoinFlowTests
public async Task ActivateAsync_WithUnknownCode_ReturnsNotFoundWithoutSideEffects()
{
var inviteStore = new TestInviteStore();
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -158,7 +158,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var service = NewService(inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var service = NewService(inviteStore, new TestTenantStore(), 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 TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var service = NewService(inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -202,7 +202,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -220,7 +220,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new TestTenantProvisioner();
@@ -247,7 +247,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -266,7 +266,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -287,7 +287,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -310,7 +310,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -356,16 +356,16 @@ public sealed class JoinFlowTests
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие).
private static JoinService NewService(
TestInviteStore inviteStore,
FakeTenantStore tenantStore,
TestTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
IPasswordHasher passwordHasher) =>
new(
new InvitesService(inviteStore.Store),
new TenantService(tenantStore, provisioner),
new TenantService(tenantStore.Repository, provisioner),
authStore,
passwordHasher,
tenantStore);
tenantStore.Repository);
// Создаёт приглашение-строку для сидирования (дефолт — живой pending без тенанта).
private static InviteDto NewInvite(
@@ -16,7 +16,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task CreateAsync_WithName_CreatesTenantAndProvisionsSchema()
{
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(tenantStore, new FakeAuthStore(), provisioner);
@@ -41,7 +41,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
{
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var hasher = TestHashers.New();
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
@@ -65,7 +65,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
{
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
var service = NewService(tenantStore, authStore);
@@ -81,7 +81,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
{
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore());
TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
@@ -94,7 +94,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
{
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore());
TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
@@ -107,7 +107,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task ListAsync_ReturnsTenantsWithUserCounts()
{
var tenantStore = new FakeTenantStore(
var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
var authStore = new FakeAuthStore();
@@ -131,7 +131,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
{
var tenantStore = new FakeTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task GetAsync_ForUnknownTenant_ReturnsNull()
{
var service = NewService(new FakeTenantStore(), new FakeAuthStore());
var service = NewService(new TestTenantStore(), new FakeAuthStore());
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
}
@@ -158,7 +158,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
{
var tenantStore = new FakeTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -173,7 +173,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
{
var tenantStore = new FakeTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore());
// Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound()
{
var service = NewService(new FakeTenantStore(), new FakeAuthStore());
var service = NewService(new TestTenantStore(), new FakeAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
@@ -198,13 +198,13 @@ public sealed class TenantAdminServiceTests
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку).
private static TenantAdminService NewService(
FakeTenantStore tenantStore,
TestTenantStore tenantStore,
FakeAuthStore authStore,
TestTenantProvisioner? provisioner = null,
IPasswordHasher? hasher = null)
{
var tenantService = new TenantService(tenantStore, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
return new TenantAdminService(tenantStore, authStore, tenantService, hasher ?? TestHashers.New());
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
return new TenantAdminService(tenantStore.Repository, authStore, tenantService, hasher ?? TestHashers.New());
}
// Пользователь тенанта (активный, без хэша — для чтения).
@@ -38,7 +38,7 @@ public sealed class JoinEndpointHttpTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var tenantStore = new FakeTenantStore();
var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore();
@@ -83,7 +83,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore();
await RunAsync(
inviteStore, new FakeTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), 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 TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), 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 TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), 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 TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), 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 TestInviteStore(), new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
new TestInviteStore(), new TestTenantStore(), 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 TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), 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 TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -226,7 +226,7 @@ public sealed class JoinEndpointHttpTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId));
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var provisioner = new TestTenantProvisioner();
var authStore = new FakeAuthStore();
@@ -250,7 +250,7 @@ public sealed class JoinEndpointHttpTests
// Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий.
private static async Task RunAsync(
TestInviteStore inviteStore,
FakeTenantStore tenantStore,
TestTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
FakeAuditLogStore auditStore,
@@ -267,7 +267,7 @@ public sealed class JoinEndpointHttpTests
builder.Services.AddSingleton<IAuthStore>(authStore);
builder.Services.AddSingleton<IAuditLogStore>(auditStore);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore);
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
WebApplication app = builder.Build();
@@ -251,7 +251,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
// ─── Хелперы ─────────────────────────────────────────────────────────
private static FakeTenantStore NewTenantStore() =>
private static TestTenantStore NewTenantStore() =>
new(
new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
@@ -88,10 +88,10 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
FakeTenantStore? tenantStore = null) =>
TestTenantStore? tenantStore = null) =>
await RunCoreAsync(
operatorStore,
userStore,
@@ -115,10 +115,10 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
FakeTenantStore? tenantStore = null,
TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null,
RateLimitOptions? rateLimitOptions = null,
FakeTokenUsageEventStore? tokenUsageStore = null) =>
@@ -163,16 +163,16 @@ internal static class OperatorAuthHttpHost
FakeAuthStore userStore,
FakeAuditLogStore? auditStore,
TestInviteStore? inviteStore,
FakeTenantStore? tenantStore,
TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null,
FakeTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null)
{
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
FakeTenantStore effectiveTenantStore = tenantStore ?? new FakeTenantStore();
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore();
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
@@ -190,7 +190,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -284,11 +284,11 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
{
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
var tenantStore = new FakeTenantStore(
var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow));
var limitStore = new FakeTenantLimitStore();
@@ -22,7 +22,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
public async Task Migrate_WithoutOperatorSession_Returns401()
{
await RunAsync(
new FakeTenantStore(),
new TestTenantStore(),
async (baseAddress, _, _, _, _, _) =>
{
HttpClient client = CreateClient(baseAddress);
@@ -38,7 +38,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
{
Guid tenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
Guid tenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
var tenantStore = new FakeTenantStore(
var tenantStore = new TestTenantStore(
new TenantRecordDto(tenantA, "A", TenantStatuses.Active, DateTimeOffset.UtcNow),
new TenantRecordDto(tenantB, "B", TenantStatuses.Active, DateTimeOffset.UtcNow));
@@ -72,7 +72,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(FakeTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -301,7 +301,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
{
var auditStore = new FakeAuditLogStore();
var tenantStore = new FakeTenantStore(
var tenantStore = new TestTenantStore(
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
await RunAsync(
@@ -464,7 +464,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
public async Task Impersonate_WhenTenantHasNoUsers_Returns400()
{
var emptyTenantId = Guid.NewGuid();
var emptyTenantStore = new FakeTenantStore(
var emptyTenantStore = new TestTenantStore(
new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
await RunAsync(
@@ -486,13 +486,13 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeAuditLogStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
FakeTenantStore? tenantStore = null)
TestTenantStore? tenantStore = null)
{
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
FakeTenantStore effectiveTenantStore = tenantStore ??
new FakeTenantStore(new TenantRecordDto(TenantId, TenantName, TenantStatuses.Active, DateTimeOffset.UtcNow));
TestTenantStore effectiveTenantStore = tenantStore ??
new TestTenantStore(new TenantRecordDto(TenantId, TenantName, TenantStatuses.Active, DateTimeOffset.UtcNow));
return OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
NewUserStore(),
@@ -0,0 +1,61 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITenantRepository"/> с поддержкой создания тенантов: сервисы получают
/// NSubstitute-подставку (<see cref="Repository"/>), тесты сеют/проверяют реестр через
/// <see cref="Seed"/> и <see cref="Tenants"/>.
/// </summary>
public sealed class TestTenantStore
{
private readonly List<TenantRecordDto> _tenants;
/// <summary>
/// Подставка порта реестра тенантов (создаётся в конструкторе).
/// </summary>
public ITenantRepository Repository { get; }
/// <summary>
/// Записи реестра в порядке добавления.
/// </summary>
public IReadOnlyList<TenantRecordDto> Tenants => _tenants;
/// <summary>
/// Создаёт хранилище, опционально с предзаполненным реестром тенантов.
/// </summary>
/// <param name="seed">Тенанты, которые уже есть в реестре (как строки public.tenants).</param>
public TestTenantStore(params TenantRecordDto[] seed)
{
_tenants = seed.ToList();
Repository = Substitute.For<ITenantRepository>();
Repository.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => _tenants.FirstOrDefault(tenant => tenant.Id == ci.ArgAt<Guid>(0)));
Repository.When(r => r.CreateAsync(Arg.Any<TenantRecordDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _tenants.Add(ci.Arg<TenantRecordDto>()));
Repository.ListAsync(Arg.Any<CancellationToken>())
.Returns(_tenants);
Repository.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<TenantRecordDto>)_tenants.Skip(ci.ArgAt<int>(0)).Take(ci.ArgAt<int>(1)).ToList());
Repository.UpdateStatusAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
int index = _tenants.FindIndex(tenant => tenant.Id == ci.ArgAt<Guid>(0));
if (index < 0)
{
return false;
}
_tenants[index] = _tenants[index] with { Status = ci.ArgAt<string>(1) };
return true;
});
}
/// <summary>
/// Кладёт тенант в реестр напрямую (псевдоним для читаемости сценариев).
/// </summary>
/// <param name="tenant">Тенант.</param>
public void Seed(TenantRecordDto tenant) => _tenants.Add(tenant);
}