From 25954c9fad9e30abf05323acb71fb986928b2165 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Sat, 12 Sep 2026 21:50:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20FakeInviteStore=20=D0=BD=D0=B0=20NSubstitute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер Support/TestInviteStore: список приглашений + журнал мутирующих вызовов, CAS-семантика TryActivateAsync сохранена. Гоночные сценарии JoinFlow переписаны на предзасев статуса (эквивалент прежнего RacingInviteStore — класс удалён). Потребители (7 файлов), тесты 1340 зелёные. --- .../Modules/Tenants/FakeInviteStore.cs | 84 ----------------- .../Modules/Tenants/InvitesServiceTests.cs | 52 +++++----- .../Modules/Tenants/JoinFlowTests.cs | 64 ++++++------- .../Support/JoinEndpointHttpTests.cs | 22 ++--- .../Support/OperatorAuthHttpHost.cs | 20 ++-- .../OperatorHealthEndpointsHttpTests.cs | 2 +- .../OperatorLimitsEndpointsHttpTests.cs | 2 +- .../OperatorMaintenanceEndpointsHttpTests.cs | 2 +- .../Support/TestInviteStore.cs | 94 +++++++++++++++++++ 9 files changed, 172 insertions(+), 170 deletions(-) delete mode 100644 src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs create mode 100644 src/core/tests/Deal.Tests.Unit/Support/TestInviteStore.cs 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); +}