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

Хелпер Support/TestInviteStore: список приглашений + журнал мутирующих вызовов,
CAS-семантика TryActivateAsync сохранена. Гоночные сценарии JoinFlow переписаны
на предзасев статуса (эквивалент прежнего RacingInviteStore — класс удалён).
Потребители (7 файлов), тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 21:50:55 +03:00
parent 1bee0003ca
commit 25954c9fad
9 changed files with 172 additions and 170 deletions
@@ -1,84 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IInviteStore"/> для юнит/HTTP-тестов приглашений.
/// </summary>
public class FakeInviteStore : IInviteStore
{
private readonly List<InviteDto> _invites = [];
private readonly List<string> _calls = [];
/// <summary>
/// Приглашения хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<InviteDto> Invites => _invites;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет приглашение
/// </summary>
/// <param name="invite">Приглашение.</param>
public void AddInvite(InviteDto invite) => _invites.Add(invite);
/// <inheritdoc />
public Task CreateAsync(InviteDto invite, CancellationToken ct)
{
_invites.Add(invite);
_calls.Add($"create:{invite.Code}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct) =>
Task.FromResult(_invites.SingleOrDefault(i => i.Code == code));
/// <inheritdoc />
public Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<InviteDto>>(_invites.OrderByDescending(i => i.CreatedAt).ToList());
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
string code,
string status,
DateTimeOffset? activatedAt,
CancellationToken ct)
{
int index = _invites.FindIndex(i => i.Code == code);
if (index < 0)
{
return Task.FromResult(false);
}
_invites[index] = _invites[index] with { Status = status, ActivatedAt = activatedAt };
_calls.Add($"update:{code}:{status}");
return Task.FromResult(true);
}
/// <inheritdoc />
public virtual Task<bool> TryActivateAsync(
string code,
DateTimeOffset activatedAt,
CancellationToken ct)
{
int index = _invites.FindIndex(i => i.Code == code && i.Status == InviteStatuses.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
_invites[index] = _invites[index] with { Status = InviteStatuses.Activated, ActivatedAt = activatedAt };
_calls.Add($"activate:{code}");
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<InviteDto?> FindActiveByEmailAsync(string email, CancellationToken ct) =>
Task.FromResult(_invites.SingleOrDefault(i => i.Email == email && i.Status == InviteStatuses.Pending));
}
@@ -1,4 +1,6 @@
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
using NSubstitute;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Modules.Tenants;
@@ -19,8 +21,8 @@ public sealed class InvitesServiceTests
[Fact]
public async Task CreateInviteAsync_WithValidEmail_StoresPendingInviteWithSixteenCharCodeAndSeventyTwoHourExpiry()
{
var store = new FakeInviteStore();
var service = new InvitesService(store);
var store = new TestInviteStore();
var service = new InvitesService(store.Store);
var before = DateTimeOffset.UtcNow;
var result = await service.CreateInviteAsync(OperatorId, " New.User@Example.COM ", tenantId: null, CancellationToken.None);
@@ -46,7 +48,7 @@ public sealed class InvitesServiceTests
[Fact]
public async Task CreateInviteAsync_ForExistingTenant_KeepsTenantId()
{
var service = new InvitesService(new FakeInviteStore());
var service = new InvitesService(new TestInviteStore().Store);
var tenantId = Guid.NewGuid();
var result = await service.CreateInviteAsync(OperatorId, FirstEmail, tenantId, CancellationToken.None);
@@ -59,8 +61,8 @@ public sealed class InvitesServiceTests
[MemberData(nameof(InvalidEmails))]
public async Task CreateInviteAsync_WithInvalidEmail_ReturnsInvalidEmailError(string? email)
{
var store = new FakeInviteStore();
var service = new InvitesService(store);
var store = new TestInviteStore();
var service = new InvitesService(store.Store);
var result = await service.CreateInviteAsync(OperatorId, email, tenantId: null, CancellationToken.None);
@@ -74,9 +76,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task CreateInviteAsync_WithDuplicatePendingEmail_ReturnsDuplicateActiveError()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "existing-pending-code", email: FirstEmail, createdAt: DateTimeOffset.UtcNow.AddHours(-1)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var result = await service.CreateInviteAsync(OperatorId, FirstEmail.ToUpperInvariant(), tenantId: null, CancellationToken.None);
@@ -90,10 +92,10 @@ public sealed class InvitesServiceTests
{
// Протухшее, но ещё не помеченное pending-приглашение не блокирует новый инвайт: сначала переводится
// в expired (иначе partial unique-индекс invites.Email по pending не пустил бы новую строку), затем создаётся новый.
var store = new FakeInviteStore();
var store = new TestInviteStore();
const string oldCode = "expired-pending-code";
store.AddInvite(NewInvite(code: oldCode, email: FirstEmail, expiresAt: DateTimeOffset.UtcNow.AddHours(-1), createdAt: DateTimeOffset.UtcNow.AddDays(-4)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var result = await service.CreateInviteAsync(OperatorId, FirstEmail, tenantId: null, CancellationToken.None);
@@ -106,9 +108,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task CreateInviteAsync_AfterRevokedInvite_AllowsNewInviteForSameEmail()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "revoked-code", email: FirstEmail, status: InviteStatuses.Revoked, createdAt: DateTimeOffset.UtcNow.AddDays(-1)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var result = await service.CreateInviteAsync(OperatorId, FirstEmail, tenantId: null, CancellationToken.None);
@@ -120,9 +122,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task RevokeAsync_WithPendingInvite_ChangesStatusToRevoked()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "pending-code", email: FirstEmail, createdAt: DateTimeOffset.UtcNow.AddHours(-1)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var result = await service.RevokeAsync("pending-code", CancellationToken.None);
@@ -135,7 +137,7 @@ public sealed class InvitesServiceTests
[Fact]
public async Task RevokeAsync_WithUnknownCode_ReturnsNotFoundError()
{
var service = new InvitesService(new FakeInviteStore());
var service = new InvitesService(new TestInviteStore().Store);
var result = await service.RevokeAsync("no-such-code", CancellationToken.None);
@@ -150,9 +152,9 @@ public sealed class InvitesServiceTests
[InlineData(InviteStatuses.Expired)]
public async Task RevokeAsync_WithNonPendingInvite_ReturnsNotPendingError(string status)
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "non-pending-code", email: FirstEmail, status: status, createdAt: DateTimeOffset.UtcNow.AddHours(-2)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var result = await service.RevokeAsync("non-pending-code", CancellationToken.None);
@@ -165,9 +167,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task GetByCodeAsync_WhenPendingInviteExpired_MarksItExpiredAndReturnsExpiredStatus()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "old-code", email: FirstEmail, expiresAt: DateTimeOffset.UtcNow.AddHours(-1), createdAt: DateTimeOffset.UtcNow.AddDays(-3)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var invite = await service.GetByCodeAsync("old-code", CancellationToken.None);
@@ -180,9 +182,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task GetByCodeAsync_WithLivePendingInvite_ReturnsPendingWithoutStoreChange()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "live-code", email: FirstEmail, createdAt: DateTimeOffset.UtcNow.AddHours(-1)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
var invite = await service.GetByCodeAsync("live-code", CancellationToken.None);
@@ -194,7 +196,7 @@ public sealed class InvitesServiceTests
[Fact]
public async Task GetByCodeAsync_WithUnknownCode_ReturnsNull()
{
var service = new InvitesService(new FakeInviteStore());
var service = new InvitesService(new TestInviteStore().Store);
Assert.Null(await service.GetByCodeAsync("no-such-code", CancellationToken.None));
}
@@ -203,11 +205,11 @@ public sealed class InvitesServiceTests
public async Task ListAsync_ReturnsNewestFirstAndMarksExpiredPendingInvites()
{
// expired проставляется и в списке (лениво при чтении), чтобы оператор видел фактический статус.
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "expired-pending", email: FirstEmail, expiresAt: DateTimeOffset.UtcNow.AddHours(-1), createdAt: DateTimeOffset.UtcNow.AddDays(-3)));
store.AddInvite(NewInvite(code: "live-pending", email: SecondEmail, createdAt: DateTimeOffset.UtcNow.AddDays(-1)));
store.AddInvite(NewInvite(code: "revoked", email: "revoked@example.com", status: InviteStatuses.Revoked, createdAt: DateTimeOffset.UtcNow.AddHours(-1)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
IReadOnlyList<InviteDto> items = await service.ListAsync(CancellationToken.None);
@@ -221,9 +223,9 @@ public sealed class InvitesServiceTests
[Fact]
public async Task ListAsync_WithExpiredNonPendingRows_DoesNotTouchThem()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(code: "activated-old", email: FirstEmail, status: InviteStatuses.Activated, expiresAt: DateTimeOffset.UtcNow.AddHours(-5), createdAt: DateTimeOffset.UtcNow.AddDays(-2)));
var service = new InvitesService(store);
var service = new InvitesService(store.Store);
IReadOnlyList<InviteDto> items = await service.ListAsync(CancellationToken.None);
@@ -25,7 +25,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithNewTenant_ReturnsOkAndCreatesUserTenantAndActivatedInvite()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore();
@@ -67,7 +67,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithNewTenantAndEmptyName_UsesEmailAsTenantName()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
@@ -82,7 +82,7 @@ public sealed class JoinFlowTests
public async Task ActivateAsync_WithExistingTenant_JoinsTenantWithoutProvisioning()
{
var tenantId = Guid.NewGuid();
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
@@ -105,7 +105,7 @@ public sealed class JoinFlowTests
public async Task ActivateAsync_WithMissingTenant_ReturnsTenantNotFoundWithoutSideEffects()
{
var tenantId = Guid.NewGuid();
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore();
@@ -123,7 +123,7 @@ public sealed class JoinFlowTests
public async Task ActivateAsync_WithSuspendedTenant_ReturnsTenantSuspendedWithoutSideEffects()
{
var tenantId = Guid.NewGuid();
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
@@ -140,7 +140,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithUnknownCode_ReturnsNotFoundWithoutSideEffects()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
@@ -156,7 +156,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithExpiredPendingInvite_ReturnsExpiredAndPersistsTransition()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
@@ -174,7 +174,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithActivatedInvite_ReturnsUsed()
{
var inviteStore = new FakeInviteStore();
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());
@@ -187,7 +187,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithRevokedInvite_ReturnsRevoked()
{
var inviteStore = new FakeInviteStore();
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());
@@ -200,7 +200,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithMismatchedEmail_ReturnsEmailMismatchAndKeepsInvitePending()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
@@ -218,7 +218,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WithTakenEmail_ReturnsEmailTakenAndKeepsInvitePending()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
@@ -245,7 +245,7 @@ public sealed class JoinFlowTests
[InlineData("123")]
public async Task ActivateAsync_WithShortPassword_ReturnsPasswordTooShort(string? password)
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
@@ -263,7 +263,7 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_SecondActivationWithSameCode_ReturnsUsedWithoutDuplicates()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new FakeTenantStore();
@@ -284,8 +284,9 @@ public sealed class JoinFlowTests
[Fact]
public async Task ActivateAsync_WhenParallelRevokeWinsBeforeCas_ReturnsRevokedWithoutSideEffects()
{
var inviteStore = new RacingInviteStore(statusBeforeActivate: InviteStatuses.Revoked);
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var inviteStore = new TestInviteStore();
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
var provisioner = new TestTenantProvisioner();
@@ -306,8 +307,9 @@ public sealed class JoinFlowTests
{
// Гонка двух активаций одного кода: победила параллельная (уже activated к моменту нашего CAS) —
// проигравшая отвечает «уже использовано» и ничего не создаёт.
var inviteStore = new RacingInviteStore(statusBeforeActivate: InviteStatuses.Activated);
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var inviteStore = new TestInviteStore();
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore();
var provisioner = new TestTenantProvisioner();
@@ -327,11 +329,11 @@ public sealed class JoinFlowTests
public async Task TryActivateAsync_AfterRevoke_RefusesTransitionAndKeepsRevoked()
{
// Контракт CAS на уровне хранилища: условный переход не перезаписывает параллельный revoke.
var store = new FakeInviteStore();
var store = new TestInviteStore();
store.AddInvite(NewInvite(Code, Email, tenantId: null));
await store.UpdateStatusAsync(Code, InviteStatuses.Revoked, null, CancellationToken.None);
await store.Store.UpdateStatusAsync(Code, InviteStatuses.Revoked, null, CancellationToken.None);
bool activated = await store.TryActivateAsync(Code, DateTimeOffset.UtcNow, CancellationToken.None);
bool activated = await store.Store.TryActivateAsync(Code, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(activated);
InviteDto invite = Assert.Single(store.Invites);
@@ -342,9 +344,9 @@ public sealed class JoinFlowTests
[Fact]
public async Task TryActivateAsync_WithUnknownCode_ReturnsFalse()
{
var store = new FakeInviteStore();
var store = new TestInviteStore();
bool activated = await store.TryActivateAsync(WrongCode, DateTimeOffset.UtcNow, CancellationToken.None);
bool activated = await store.Store.TryActivateAsync(WrongCode, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(activated);
}
@@ -353,13 +355,13 @@ public sealed class JoinFlowTests
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие).
private static JoinService NewService(
FakeInviteStore inviteStore,
TestInviteStore inviteStore,
FakeTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
IPasswordHasher passwordHasher) =>
new(
new InvitesService(inviteStore),
new InvitesService(inviteStore.Store),
new TenantService(tenantStore, provisioner),
authStore,
passwordHasher,
@@ -382,17 +384,5 @@ public sealed class JoinFlowTests
CreatedById: Guid.NewGuid(),
DateTimeOffset.UtcNow);
// FakeInviteStore, имитирующий гонку: к моменту CAS-вызова конкурент уже перевёл строку в заданный статус.
private sealed class RacingInviteStore(string statusBeforeActivate) : FakeInviteStore
{
public override Task<bool> TryActivateAsync(
string code,
DateTimeOffset activatedAt,
CancellationToken ct)
{
// Конкурент (отзыв/другая активация) «успел» до нашего CAS — активация обязана не выполниться.
_ = base.UpdateStatusAsync(code, statusBeforeActivate, null, ct);
return base.TryActivateAsync(code, activatedAt, ct);
}
}
// TestInviteStore, имитирующий гонку: к моменту CAS-вызова конкурент уже перевёл строку в заданный статус.
}
@@ -35,7 +35,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithValidPendingInvite_ReturnsOkCreatesUserAndWritesAudit()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var tenantStore = new FakeTenantStore();
@@ -78,7 +78,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithSameCodeTwice_SecondReturns400AlreadyUsed()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
@@ -106,7 +106,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithMismatchedEmail_Returns400EmailMismatchAndKeepsInvite()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
@@ -128,7 +128,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithRevokedInvite_Returns400Revoked()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync(
@@ -146,7 +146,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithExpiredInvite_Returns400Expired()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound()
{
await RunAsync(
new FakeInviteStore(), new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
new TestInviteStore(), new FakeTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -179,7 +179,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithShortPassword_Returns400PasswordTooShort()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
await RunAsync(
@@ -199,7 +199,7 @@ public sealed class JoinEndpointHttpTests
[Fact]
public async Task Join_WithTakenEmail_Returns400EmailAlreadyRegistered()
{
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
@@ -223,7 +223,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_ExistingTenantInvite_JoinsTenantWithoutCreatingNewOne()
{
var tenantId = Guid.NewGuid();
var inviteStore = new FakeInviteStore();
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId));
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
@@ -249,7 +249,7 @@ public sealed class JoinEndpointHttpTests
// Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий.
private static async Task RunAsync(
FakeInviteStore inviteStore,
TestInviteStore inviteStore,
FakeTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
@@ -266,7 +266,7 @@ public sealed class JoinEndpointHttpTests
builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore);
builder.Services.AddSingleton<IAuditLogStore>(auditStore);
builder.Services.AddSingleton<IInviteStore>(inviteStore);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
@@ -64,9 +64,9 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeInviteStore, FakeAuditLogStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
FakeInviteStore? inviteStore = null) =>
TestInviteStore? inviteStore = null) =>
await RunCoreAsync(
operatorStore,
userStore,
@@ -88,9 +88,9 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
FakeInviteStore? inviteStore = null,
TestInviteStore? inviteStore = null,
FakeTenantStore? tenantStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -115,9 +115,9 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
FakeInviteStore? inviteStore = null,
TestInviteStore? inviteStore = null,
FakeTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null,
RateLimitOptions? rateLimitOptions = null,
@@ -162,16 +162,16 @@ internal static class OperatorAuthHttpHost
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
FakeAuditLogStore? auditStore,
FakeInviteStore? inviteStore,
TestInviteStore? inviteStore,
FakeTenantStore? tenantStore,
FakeTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null,
FakeTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null)
{
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
FakeInviteStore effectiveInviteStore = inviteStore ?? new FakeInviteStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
FakeTenantStore effectiveTenantStore = tenantStore ?? new FakeTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore();
@@ -189,7 +189,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IAuthStore>(userStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
{
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
@@ -72,7 +72,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(FakeTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, FakeInviteStore, FakeAuditLogStore, Task> scenario) =>
private static Task RunAsync(FakeTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, FakeTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -0,0 +1,94 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IInviteStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют приглашения через <see cref="AddInvite"/> и проверяют
/// состояние через <see cref="Invites"/> и <see cref="Calls"/>.
/// </summary>
public class TestInviteStore
{
private readonly List<InviteDto> _invites = [];
private readonly List<string> _calls = [];
/// <summary>
/// Подставка порта приглашений (создаётся в конструкторе).
/// </summary>
public IInviteStore Store { get; }
/// <summary>
/// Приглашения хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<InviteDto> Invites => _invites;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Создаёт подставку с пустым хранилищем.
/// </summary>
public TestInviteStore()
{
Store = Substitute.For<IInviteStore>();
Store.When(s => s.CreateAsync(Arg.Any<InviteDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
InviteDto invite = ci.Arg<InviteDto>();
_invites.Add(invite);
_calls.Add($"create:{invite.Code}");
});
Store.GetByCodeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _invites.SingleOrDefault(i => i.Code == ci.ArgAt<string>(0)));
Store.ListAsync(Arg.Any<CancellationToken>())
.Returns(ci => _invites.OrderByDescending(i => i.CreatedAt).ToList());
Store.UpdateStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
int index = _invites.FindIndex(i => i.Code == ci.ArgAt<string>(0));
if (index < 0)
{
return false;
}
_invites[index] = _invites[index] with
{
Status = ci.ArgAt<string>(1),
ActivatedAt = ci.ArgAt<DateTimeOffset?>(2),
};
_calls.Add($"update:{ci.ArgAt<string>(0)}:{ci.ArgAt<string>(1)}");
return true;
});
Store.TryActivateAsync(Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
int index = _invites.FindIndex(i =>
i.Code == ci.ArgAt<string>(0) && i.Status == InviteStatuses.Pending);
if (index < 0)
{
return false;
}
_invites[index] = _invites[index] with
{
Status = InviteStatuses.Activated,
ActivatedAt = ci.ArgAt<DateTimeOffset>(1),
};
_calls.Add($"activate:{ci.ArgAt<string>(0)}");
return true;
});
Store.FindActiveByEmailAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _invites.SingleOrDefault(i =>
i.Email == ci.ArgAt<string>(0) && i.Status == InviteStatuses.Pending));
}
/// <summary>
/// Добавляет приглашение
/// </summary>
/// <param name="invite">Приглашение.</param>
public void AddInvite(InviteDto invite) => _invites.Add(invite);
}