diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs
deleted file mode 100644
index 8ec25bf..0000000
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Models;
-
-namespace Deal.Tests.Unit.Modules.Tenants;
-
-///
-/// In-memory реализация для юнит/HTTP-тестов приглашений.
-///
-public class FakeInviteStore : IInviteStore
-{
- private readonly List _invites = [];
- private readonly List _calls = [];
-
- ///
- /// Приглашения хранилища в порядке добавления.
- ///
- public IReadOnlyList Invites => _invites;
-
- ///
- /// Журнал мутирующих вызовов в порядке их совершения.
- ///
- public IReadOnlyList Calls => _calls;
-
- ///
- /// Добавляет приглашение
- ///
- /// Приглашение.
- public void AddInvite(InviteDto invite) => _invites.Add(invite);
-
- ///
- public Task CreateAsync(InviteDto invite, CancellationToken ct)
- {
- _invites.Add(invite);
- _calls.Add($"create:{invite.Code}");
- return Task.CompletedTask;
- }
-
- ///
- public Task GetByCodeAsync(string code, CancellationToken ct) =>
- Task.FromResult(_invites.SingleOrDefault(i => i.Code == code));
-
- ///
- public Task> ListAsync(CancellationToken ct) =>
- Task.FromResult>(_invites.OrderByDescending(i => i.CreatedAt).ToList());
-
- ///
- public Task 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);
- }
-
- ///
- public virtual Task 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);
- }
-
- ///
- public Task FindActiveByEmailAsync(string email, CancellationToken ct) =>
- Task.FromResult(_invites.SingleOrDefault(i => i.Email == email && i.Status == InviteStatuses.Pending));
-}
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs
index 5b0d09a..e9371ae 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs
@@ -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 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 items = await service.ListAsync(CancellationToken.None);
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs
index 848f194..782caf6 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs
@@ -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 TryActivateAsync(
- string code,
- DateTimeOffset activatedAt,
- CancellationToken ct)
- {
- // Конкурент (отзыв/другая активация) «успел» до нашего CAS — активация обязана не выполниться.
- _ = base.UpdateStatusAsync(code, statusBeforeActivate, null, ct);
- return base.TryActivateAsync(code, activatedAt, ct);
- }
- }
+ // TestInviteStore, имитирующий гонку: к моменту CAS-вызова конкурент уже перевёл строку в заданный статус.
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
index 94b5b55..e6f5d51 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
@@ -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(authStore);
builder.Services.AddSingleton(auditStore);
- builder.Services.AddSingleton(inviteStore);
+ builder.Services.AddSingleton(inviteStore.Store);
builder.Services.AddSingleton(tenantStore);
builder.Services.AddSingleton(provisioner);
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
index 3c3932f..9be025c 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs
@@ -64,9 +64,9 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
- Func scenario,
+ Func 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 scenario,
+ Func 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 scenario,
+ Func 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 scenario,
+ Func 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(userStore);
builder.Services.AddSingleton(operatorStore);
builder.Services.AddSingleton(effectiveAuditStore);
- builder.Services.AddSingleton(effectiveInviteStore);
+ builder.Services.AddSingleton(effectiveInviteStore.Store);
builder.Services.AddSingleton(effectiveTenantStore);
builder.Services.AddSingleton(effectiveLimitStore);
builder.Services.AddSingleton(effectiveGlobalSettingsStore.Store);
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs
index 9665900..1eaad3f 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
- private static Task RunAsync(Func scenario) =>
+ private static Task RunAsync(Func scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator.
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
index 7fe32cb..ad954d0 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
- private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null)
+ private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null)
{
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs
index ea573c7..ccd0d79 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs
@@ -72,7 +72,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
- private static Task RunAsync(FakeTenantStore tenantStore, Func scenario) =>
+ private static Task RunAsync(FakeTenantStore tenantStore, Func scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator.
diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestInviteStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestInviteStore.cs
new file mode 100644
index 0000000..bdf8c82
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Support/TestInviteStore.cs
@@ -0,0 +1,94 @@
+using Deal.Modules.Tenants.Application.Abstractions;
+using Deal.Modules.Tenants.Application.Models;
+using NSubstitute;
+
+namespace Deal.Tests.Unit.Support;
+
+///
+/// Подставка на списках: сервисы получают NSubstitute-подставку
+/// (), тесты сеют приглашения через и проверяют
+/// состояние через и .
+///
+public class TestInviteStore
+{
+ private readonly List _invites = [];
+ private readonly List _calls = [];
+
+ ///
+ /// Подставка порта приглашений (создаётся в конструкторе).
+ ///
+ public IInviteStore Store { get; }
+
+ ///
+ /// Приглашения хранилища в порядке добавления.
+ ///
+ public IReadOnlyList Invites => _invites;
+
+ ///
+ /// Журнал мутирующих вызовов в порядке их совершения.
+ ///
+ public IReadOnlyList Calls => _calls;
+
+ ///
+ /// Создаёт подставку с пустым хранилищем.
+ ///
+ public TestInviteStore()
+ {
+ Store = Substitute.For();
+ Store.When(s => s.CreateAsync(Arg.Any(), Arg.Any()))
+ .Do(ci =>
+ {
+ InviteDto invite = ci.Arg();
+ _invites.Add(invite);
+ _calls.Add($"create:{invite.Code}");
+ });
+ Store.GetByCodeAsync(Arg.Any(), Arg.Any())
+ .Returns(ci => _invites.SingleOrDefault(i => i.Code == ci.ArgAt(0)));
+ Store.ListAsync(Arg.Any())
+ .Returns(ci => _invites.OrderByDescending(i => i.CreatedAt).ToList());
+ Store.UpdateStatusAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ci =>
+ {
+ int index = _invites.FindIndex(i => i.Code == ci.ArgAt(0));
+ if (index < 0)
+ {
+ return false;
+ }
+
+ _invites[index] = _invites[index] with
+ {
+ Status = ci.ArgAt(1),
+ ActivatedAt = ci.ArgAt(2),
+ };
+ _calls.Add($"update:{ci.ArgAt(0)}:{ci.ArgAt(1)}");
+ return true;
+ });
+ Store.TryActivateAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ci =>
+ {
+ int index = _invites.FindIndex(i =>
+ i.Code == ci.ArgAt(0) && i.Status == InviteStatuses.Pending);
+ if (index < 0)
+ {
+ return false;
+ }
+
+ _invites[index] = _invites[index] with
+ {
+ Status = InviteStatuses.Activated,
+ ActivatedAt = ci.ArgAt(1),
+ };
+ _calls.Add($"activate:{ci.ArgAt(0)}");
+ return true;
+ });
+ Store.FindActiveByEmailAsync(Arg.Any(), Arg.Any())
+ .Returns(ci => _invites.SingleOrDefault(i =>
+ i.Email == ci.ArgAt(0) && i.Status == InviteStatuses.Pending));
+ }
+
+ ///
+ /// Добавляет приглашение
+ ///
+ /// Приглашение.
+ public void AddInvite(InviteDto invite) => _invites.Add(invite);
+}