Хелпер Support/TestInviteStore: список приглашений + журнал мутирующих вызовов, CAS-семантика TryActivateAsync сохранена. Гоночные сценарии JoinFlow переписаны на предзасев статуса (эквивалент прежнего RacingInviteStore — класс удалён). Потребители (7 файлов), тесты 1340 зелёные.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user