diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 09dc111a9..6ce388d3f 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -226,6 +226,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Orbit.Application/Auth/Commands/AccountReactivation.cs b/src/Orbit.Application/Auth/Commands/AccountReactivation.cs new file mode 100644 index 000000000..2b08bcc06 --- /dev/null +++ b/src/Orbit.Application/Auth/Commands/AccountReactivation.cs @@ -0,0 +1,25 @@ +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Auth.Commands; + +public sealed class AccountReactivation( + IGenericRepository owedCancellations) +{ + public async Task ReactivateAsync(User user, CancellationToken cancellationToken) + { + if (!user.IsDeactivated) + return null; + + var playToken = user.PlayRenewalStoppedByDeletion ? user.PlayPurchaseToken : null; + if (playToken is not null) + { + await owedCancellations.DeleteAsync(record => record.Provider == SubscriptionSource.GooglePlay + && record.ProviderIdentifier == playToken, cancellationToken); + } + + user.CancelDeactivation(); + return playToken; + } +} diff --git a/src/Orbit.Application/Auth/Commands/GoogleSignInFlow.cs b/src/Orbit.Application/Auth/Commands/GoogleSignInFlow.cs index c195ad2dd..294008f5a 100644 --- a/src/Orbit.Application/Auth/Commands/GoogleSignInFlow.cs +++ b/src/Orbit.Application/Auth/Commands/GoogleSignInFlow.cs @@ -13,7 +13,8 @@ public class GoogleSignInFlow( IUnitOfWork unitOfWork, IAuthSessionService authSessionService, ISignupJobDispatcher signupJobDispatcher, - IBackgroundJobClient backgroundJobClient) + IBackgroundJobClient backgroundJobClient, + AccountReactivation accountReactivation) { public async Task> CompleteAsync( string rawEmail, string name, string language, string? referralCode, @@ -21,6 +22,7 @@ public async Task> CompleteAsync( { var email = rawEmail.Trim().ToLowerInvariant(); User? reactivatedUser = null; + string? reactivatedPlayToken = null; var signupPending = false; var signupLanguage = language; var signupReferralCode = referralCode; @@ -38,7 +40,7 @@ public async Task> CompleteAsync( var wasReactivated = user.IsDeactivated; if (wasReactivated) { - user.CancelDeactivation(); + reactivatedPlayToken = await accountReactivation.ReactivateAsync(user, transactionToken); reactivatedUser = user; } @@ -60,7 +62,10 @@ public async Task> CompleteAsync( return result; if (result.Value.WasReactivated && reactivatedUser is not null) + { CancelSubscriptionOnDeletionJob.EnqueueRestoration(backgroundJobClient, reactivatedUser); + NotifyPlayRenewalOnReactivationJob.Enqueue(backgroundJobClient, reactivatedUser.Id, reactivatedPlayToken); + } signupJobDispatcher.Dispatch(result.Value.UserId, signupPending, signupLanguage, signupReferralCode); diff --git a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs index 5e62f6842..e6eb8b818 100644 --- a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs +++ b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs @@ -20,7 +20,8 @@ public class VerifyCodeCommandHandler( IUnitOfWork unitOfWork, IAuthSessionService authSessionService, ISignupJobDispatcher signupJobDispatcher, - IBackgroundJobClient backgroundJobClient) : IRequestHandler> + IBackgroundJobClient backgroundJobClient, + AccountReactivation accountReactivation) : IRequestHandler> { public async Task> Handle(VerifyCodeCommand request, CancellationToken cancellationToken) { @@ -31,6 +32,7 @@ public async Task> Handle(VerifyCodeCommand request, Cance return codeValidation.PropagateError(); User? reactivatedUser = null; + string? reactivatedPlayToken = null; var signupPending = false; var signupLanguage = request.Language; var signupReferralCode = request.ReferralCode; @@ -48,7 +50,7 @@ public async Task> Handle(VerifyCodeCommand request, Cance var wasReactivated = user.IsDeactivated; if (wasReactivated) { - user.CancelDeactivation(); + reactivatedPlayToken = await accountReactivation.ReactivateAsync(user, transactionToken); reactivatedUser = user; } await unitOfWork.SaveChangesAsync(transactionToken); @@ -70,7 +72,10 @@ public async Task> Handle(VerifyCodeCommand request, Cance return result; if (result.Value.WasReactivated && reactivatedUser is not null) + { CancelSubscriptionOnDeletionJob.EnqueueRestoration(backgroundJobClient, reactivatedUser); + NotifyPlayRenewalOnReactivationJob.Enqueue(backgroundJobClient, reactivatedUser.Id, reactivatedPlayToken); + } signupJobDispatcher.Dispatch(result.Value.UserId, signupPending, signupLanguage, signupReferralCode); diff --git a/src/Orbit.Application/Auth/Jobs/NotifyPlayRenewalOnReactivationJob.cs b/src/Orbit.Application/Auth/Jobs/NotifyPlayRenewalOnReactivationJob.cs new file mode 100644 index 000000000..beda1f374 --- /dev/null +++ b/src/Orbit.Application/Auth/Jobs/NotifyPlayRenewalOnReactivationJob.cs @@ -0,0 +1,59 @@ +using Hangfire; +using Microsoft.Extensions.Options; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Auth.Jobs; + +public sealed class NotifyPlayRenewalOnReactivationJob( + IGenericRepository users, + IGenericRepository notifications, + IUnitOfWork unitOfWork, + IPlayBillingService play, + IOptions playSettings) +{ + public static void Enqueue(IBackgroundJobClient jobs, Guid userId, string? purchaseToken) + { + if (string.IsNullOrWhiteSpace(purchaseToken)) + return; + + var reactivationId = Guid.NewGuid(); + jobs.Enqueue(job => + job.ExecuteAsync(userId, purchaseToken, reactivationId, CancellationToken.None)); + } + + [AutomaticRetry(Attempts = 26, DelaysInSeconds = new[] { 60, 300, 3600 }, OnAttemptsExceeded = AttemptsExceededAction.Fail)] + public async Task ExecuteAsync(Guid userId, string purchaseToken, Guid reactivationId, CancellationToken cancellationToken) + { + if (unitOfWork.HasActiveTransaction) + throw new InvalidOperationException("Play renewal verification requires no active transaction."); + + var purchase = await play.VerifyAsync(playSettings.Value.ProductId, purchaseToken, cancellationToken); + if (purchase is not { CanRenew: false }) + return; + + unitOfWork.ResetTracking(); + await unitOfWork.ExecuteInTransactionAsync(async ct => + { + await unitOfWork.AcquireUserRowLockAsync(userId, ct); + var user = await users.FindOneTrackedIgnoringFiltersAsync(user => user.Id == userId, ct); + if (user is null || user.IsDeactivated || user.PlayPurchaseToken != purchaseToken) + return; + + var dedupeKey = $"play-reactivation:{userId}:{reactivationId}"; + if (await notifications.FindOneTrackedIgnoringFiltersAsync(notice => notice.DedupeKey == dedupeKey, ct) is not null) + return; + + var isPortuguese = LocaleHelper.IsPortuguese(user.Language); + var title = isPortuguese + ? "Sua renovação no Google Play está desativada" + : "Your Google Play renewal is off"; + var body = isPortuguese + ? "Sua conta do Orbit está ativa de novo. A renovação no Google Play foi desativada quando você pediu a exclusão da conta. Para voltar a renovar, abra o Google Play, vá em Pagamentos e assinaturas > Assinaturas, escolha Orbit e toque em Assinar novamente. Se essa opção não aparecer, assine o Pro de novo pelo Orbit." + : "Your Orbit account is active again. Deleting your account turned off your Google Play renewal. To renew again, open Google Play, go to Payments & subscriptions > Subscriptions, select Orbit, and tap Resubscribe. If that option is unavailable, subscribe to Pro again in Orbit."; + await notifications.AddAsync(Notification.Create(user.Id, title, body, dedupeKey: dedupeKey), ct); + await unitOfWork.SaveChangesAsync(ct); + }, cancellationToken); + } +} diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index b6801e2d6..313741681 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -549,6 +549,7 @@ public void Deactivate(DateTime scheduledDeletion) public void CancelDeactivation() { + PlayRenewalStoppedByDeletion = false; if (!IsLifetimePro && PlanExpiresAt.HasValue && PlanExpiresAt.Value <= DateTime.UtcNow) Plan = UserPlan.Free; diff --git a/tests/Orbit.Application.Tests/Commands/Auth/AuthCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/AuthCommandHandlerTests.cs index ea29b52d3..f1f47fb81 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/AuthCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/AuthCommandHandlerTests.cs @@ -84,7 +84,7 @@ public async Task VerifyCode_Valid_ReturnsLoginResponse() _authSessionService.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token"))); - var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); var command = new VerifyCodeCommand(TestEmail, "123456"); var result = await handler.Handle(command, CancellationToken.None); @@ -100,7 +100,7 @@ public async Task VerifyCode_WrongCode_ReturnsFailure() { SetupCacheWithCode("123456"); - var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); var command = new VerifyCodeCommand(TestEmail, "999999"); var result = await handler.Handle(command, CancellationToken.None); @@ -118,7 +118,7 @@ public async Task VerifyCode_MaxAttempts_ReturnsFailure() new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) }); SetupCacheWithCode("123456"); - var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); var command = new VerifyCodeCommand(TestEmail, "123456"); var result = await handler.Handle(command, CancellationToken.None); @@ -134,7 +134,7 @@ public async Task VerifyCode_NewUser_CreatesAccount() _authSessionService.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token"))); - var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); var command = new VerifyCodeCommand(TestEmail, "123456"); var result = await handler.Handle(command, CancellationToken.None); @@ -153,7 +153,7 @@ public async Task VerifyCode_ExistingUser_ReturnsToken() _authSessionService.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token"))); - var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); var command = new VerifyCodeCommand(TestEmail, "123456"); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Auth/AuthRegistrationWorkTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/AuthRegistrationWorkTests.cs index 7b6d61cc3..22de777b8 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/AuthRegistrationWorkTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/AuthRegistrationWorkTests.cs @@ -63,7 +63,7 @@ private static async Task> SignInAsync(bool google, bool exists, bo }); if (google) { - var flow = new GoogleSignInFlow(users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + var flow = new GoogleSignInFlow(users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); (await flow.CompleteAsync("person@example.com", "Person", "en", withExtras ? "friend" : null, withExtras ? "access" : null, withExtras ? "refresh" : null, CancellationToken.None)).IsSuccess.Should().BeTrue(); } @@ -71,7 +71,7 @@ private static async Task> SignInAsync(bool google, bool exists, bo { using var cache = new MemoryCache(new MemoryCacheOptions()); cache.Set("verify:person@example.com", new VerificationEntry("123456", 0, DateTime.UtcNow)); - var handler = new VerifyCodeCommandHandler(cache, users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + var handler = new VerifyCodeCommandHandler(cache, users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); (await handler.Handle(new VerifyCodeCommand("person@example.com", "123456", ReferralCode: withExtras ? "friend" : null), CancellationToken.None)).IsSuccess.Should().BeTrue(); } diff --git a/tests/Orbit.Application.Tests/Commands/Auth/AuthSignupDispatchTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/AuthSignupDispatchTests.cs index 893c10e85..12d907b38 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/AuthSignupDispatchTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/AuthSignupDispatchTests.cs @@ -108,11 +108,11 @@ public Scenario() public Task> SignInAsync(bool google, string language, string referral) { if (google) - return new GoogleSignInFlow(_users, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs).CompleteAsync( + return new GoogleSignInFlow(_users, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs, new AccountReactivation(Substitute.For>())).CompleteAsync( "person@example.com", "Person", language, referral, null, null, CancellationToken.None); Cache.Set("verify:person@example.com", new VerificationEntry("123456", 0, DateTime.UtcNow)); - return new VerifyCodeCommandHandler(Cache, _users, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs).Handle( + return new VerifyCodeCommandHandler(Cache, _users, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs, new AccountReactivation(Substitute.For>())).Handle( new VerifyCodeCommand("person@example.com", "123456", language, referral), CancellationToken.None); } diff --git a/tests/Orbit.Application.Tests/Commands/Auth/GoogleCodeAuthCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/GoogleCodeAuthCommandHandlerTests.cs index d579d35cb..6a28a167b 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/GoogleCodeAuthCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/GoogleCodeAuthCommandHandlerTests.cs @@ -26,6 +26,7 @@ namespace Orbit.Application.Tests.Commands.Auth; public class GoogleCodeAuthCommandHandlerTests { + private const string Email = "google@example.com"; private readonly IGoogleAuthorizationCodeService _exchange = Substitute.For(); private readonly IGenericRepository _users = Substitute.For>(); @@ -41,7 +42,7 @@ public GoogleCodeAuthCommandHandlerTests() _work.ExecuteInTransactionAsync(Arg.Any>>>(), Arg.Any()) .Returns(call => call.Arg>>>()(call.Arg())); _store.UpsertAsync(Arg.Any(), Arg.Any()).Returns(call => call.Arg()); - var flow = new GoogleSignInFlow(_store, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs); + var flow = new GoogleSignInFlow(_store, _work, _sessions, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs, new AccountReactivation(Substitute.For>())); _handler = new GoogleCodeAuthCommandHandler(_exchange, _signInMediator); _signInMediator.Send(Arg.Any(), Arg.Any()) .Returns(call => @@ -72,6 +73,8 @@ public async Task Reactivation_RestoresOnlyUnexpiredStripeRenewal(bool expired, user.SetStripeSubscription("sub_paid", expiryAtUtc); user.StartTrial(DateTime.UtcNow.AddDays(-1)); user.Deactivate(DateTime.UtcNow.AddDays(7)); + if (play) + user.RecordDeletionStoppedRenewal(SubscriptionSource.GooglePlay, "play-token"); Job? enqueued = null; _jobs.Create(Arg.Do(job => { if (job.Type == typeof(CancelSubscriptionOnDeletionJob)) enqueued = job; }), Arg.Any()); @@ -81,11 +84,15 @@ public async Task Reactivation_RestoresOnlyUnexpiredStripeRenewal(bool expired, result.Value.WasReactivated.Should().BeTrue(); user.IsDeactivated.Should().BeFalse(); user.ScheduledDeletionAt.Should().BeNull(); + user.PlayRenewalStoppedByDeletion.Should().BeFalse("reactivation must resolve deletion-owned Play renewal"); + _jobs.Received(play ? 1 : 0).Create(Arg.Is(job => job.Type == typeof(NotifyPlayRenewalOnReactivationJob) + && (Guid)job.Args[0] == user.Id && (string)job.Args[1] == "play-token" + && (Guid)job.Args[2] != Guid.Empty), Arg.Any()); user.HasProAccess.Should().Be(!expired); user.Plan.Should().Be(expired ? UserPlan.Free : UserPlan.Pro); _jobs.Received(1).Create(Arg.Is(job => job.Type == typeof(CompleteSignupJob) && (Guid)job.Args[0] == user.Id && !(bool)job.Args[1]), Arg.Any()); - _jobs.Received(!expired && !play ? 2 : 1).Create(Arg.Any(), Arg.Any()); + _jobs.Received(play || !expired ? 2 : 1).Create(Arg.Any(), Arg.Any()); if (!expired && !play) { _jobs.Received(1).Create(Arg.Is(job => job.Type == typeof(CancelSubscriptionOnDeletionJob)), Arg.Any()); @@ -233,6 +240,7 @@ public async Task ConcurrencyConflict_RetriesSignInWithoutRedeemingCodeAgain() cfg.AddOpenBehavior(typeof(ConcurrencyRetryBehavior<,>)); }) .AddScoped() + .AddSingleton(new AccountReactivation(Substitute.For>())) .AddSingleton(new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance)) .AddSingleton(_store) .AddSingleton(_jobs) diff --git a/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs index 5b9218a9b..b8d599338 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs @@ -208,7 +208,7 @@ private VerifyCodeCommandHandler BuildVerifyHandler() .Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token"))); return new VerifyCodeCommandHandler( - _cache, userRepo, unitOfWork, authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient); + _cache, userRepo, unitOfWork, authSessionService, new SignupJobDispatcher(_backgroundJobClient, new HttpContextAccessor(), NullLogger.Instance), _backgroundJobClient, new AccountReactivation(Substitute.For>())); } private static async Task WithEnv(string aspNetEnv, string? smokeEmail, string? smokeCode, Func body) diff --git a/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs index 1fed58096..320abbbcc 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs @@ -36,7 +36,7 @@ public VerifyCodeCommandHandlerTests() _unitOfWork.ExecuteInTransactionAsync(Arg.Any>>>(), Arg.Any()) .Returns(call => call.Arg>>>()(call.Arg())); _handler = new VerifyCodeCommandHandler( - _cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs); + _cache, _userRepo, _unitOfWork, _authSessionService, new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance), _jobs, new AccountReactivation(Substitute.For>())); _userRepo.UpsertAsync(Arg.Any(), Arg.Any()).Returns(call => call.Arg()); _authSessionService.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -60,6 +60,8 @@ public async Task Reactivation_RestoresOnlyUnexpiredStripeRenewal(bool expired, user.SetStripeSubscription("sub_paid", expiryAtUtc); user.StartTrial(DateTime.UtcNow.AddDays(-1)); user.Deactivate(DateTime.UtcNow.AddDays(7)); + if (play) + user.RecordDeletionStoppedRenewal(SubscriptionSource.GooglePlay, "play-token"); Job? enqueued = null; _jobs.Create(Arg.Do(job => { if (job.Type == typeof(CancelSubscriptionOnDeletionJob)) enqueued = job; }), Arg.Any()); @@ -69,11 +71,15 @@ public async Task Reactivation_RestoresOnlyUnexpiredStripeRenewal(bool expired, result.Value.WasReactivated.Should().BeTrue(); user.IsDeactivated.Should().BeFalse(); user.ScheduledDeletionAt.Should().BeNull(); + user.PlayRenewalStoppedByDeletion.Should().BeFalse("reactivation must resolve deletion-owned Play renewal"); + _jobs.Received(play ? 1 : 0).Create(Arg.Is(job => job.Type == typeof(NotifyPlayRenewalOnReactivationJob) + && (Guid)job.Args[0] == user.Id && (string)job.Args[1] == "play-token" + && (Guid)job.Args[2] != Guid.Empty), Arg.Any()); user.HasProAccess.Should().Be(!expired); user.Plan.Should().Be(expired ? UserPlan.Free : UserPlan.Pro); _jobs.Received(1).Create(Arg.Is(job => job.Type == typeof(CompleteSignupJob) && (Guid)job.Args[0] == user.Id && !(bool)job.Args[1]), Arg.Any()); - _jobs.Received(!expired && !play ? 2 : 1).Create(Arg.Any(), Arg.Any()); + _jobs.Received(play || !expired ? 2 : 1).Create(Arg.Any(), Arg.Any()); if (!expired && !play) { _jobs.Received(1).Create(Arg.Is(job => job.Type == typeof(CancelSubscriptionOnDeletionJob)), Arg.Any()); diff --git a/tests/Orbit.Domain.Tests/Entities/UserDeletionCancellationTests.cs b/tests/Orbit.Domain.Tests/Entities/UserDeletionCancellationTests.cs index e9731bb11..557eaa2a2 100644 --- a/tests/Orbit.Domain.Tests/Entities/UserDeletionCancellationTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/UserDeletionCancellationTests.cs @@ -9,7 +9,7 @@ public class UserDeletionCancellationTests [Theory] [InlineData(SubscriptionSource.Stripe)] [InlineData(SubscriptionSource.GooglePlay)] - public void CancellationOwnership_SurvivesReactivationAndSameSubscriptionWebhook(SubscriptionSource source) + public void Reactivation_ResolvesPlayOwnershipAndPreservesStripeRestoration(SubscriptionSource source) { var user = CreatePaidUser(); var identifier = source == SubscriptionSource.Stripe ? "sub_paid" : "play-token"; @@ -21,7 +21,7 @@ public void CancellationOwnership_SurvivesReactivationAndSameSubscriptionWebhook user.SetPlaySubscription(identifier, DateTime.UtcNow.AddDays(20)); user.StripeRenewalStoppedByDeletion.Should().Be(source == SubscriptionSource.Stripe); - user.PlayRenewalStoppedByDeletion.Should().Be(source == SubscriptionSource.GooglePlay); + user.PlayRenewalStoppedByDeletion.Should().BeFalse(); if (source == SubscriptionSource.Stripe) { user.RecordStripeRenewalRestored(identifier); diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs index 7e1cef579..db7790987 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/AuthControllerTests.cs @@ -20,6 +20,7 @@ using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Interfaces; +using Orbit.Domain.Entities; using Orbit.Domain.Models; namespace Orbit.Infrastructure.Tests.Controllers; @@ -71,7 +72,7 @@ public async Task VerifyCode_AnonymousFailure_IsIdenticalRegardlessOfRegistratio new MemoryCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(-1) }); if (mode == "throttled") cache.Set("verify-attempts:person@example.com", 3); - var handler = new VerifyCodeCommandHandler(cache, store, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + var handler = new VerifyCodeCommandHandler(cache, store, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(call => handler.Handle(call.Arg(), call.Arg())); diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/OAuthControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/OAuthControllerTests.cs index c100a3692..75eb293df 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/OAuthControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/OAuthControllerTests.cs @@ -108,7 +108,7 @@ public async Task GoogleCallback_Reactivation_RestoresOnlyUnexpiredStripeRenewal Job? enqueued = null; jobs.Create(Arg.Do(job => { if (job.Type == typeof(CancelSubscriptionOnDeletionJob)) enqueued = job; }), Arg.Any()); var flow = new GoogleSignInFlow(users, _unitOfWork, sessions, - new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); var exchange = Substitute.For(); exchange.ExchangeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(new GoogleCodeIdentity(user.Email, user.Name, "google-access", "google-refresh"))); @@ -166,7 +166,7 @@ public async Task GoogleCallback_RegistrationDoesNotChangeWorkAndUsesEmailProvis sessions.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => { calls.Add("session"); return Result.Success(new SessionTokens("access", "refresh")); }); jobs.Create(Arg.Any(), Arg.Any()).Returns(_ => { calls.Add("enqueue"); return "job"; }); - var flow = new GoogleSignInFlow(users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + var flow = new GoogleSignInFlow(users, work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); var completion = new CompleteGoogleSignInCommandHandler(flow); var codeHandler = new GoogleCodeAuthCommandHandler(exchange, _mediator); _mediator.Send(Arg.Any(), Arg.Any()) @@ -195,7 +195,7 @@ public async Task GoogleCallback_RegistrationDoesNotChangeWorkAndUsesEmailProvis var emailWork = Substitute.For(); emailWork.ExecuteInTransactionAsync(Arg.Any>>>(), Arg.Any()) .Returns(call => call.Arg>>>()(call.Arg())); - var verify = new VerifyCodeCommandHandler(cache, emailStore, emailWork, emailSessions, new SignupJobDispatcher(Substitute.For(), new HttpContextAccessor(), NullLogger.Instance), Substitute.For()); + var verify = new VerifyCodeCommandHandler(cache, emailStore, emailWork, emailSessions, new SignupJobDispatcher(Substitute.For(), new HttpContextAccessor(), NullLogger.Instance), Substitute.For(), new AccountReactivation(Substitute.For>())); (await verify.Handle(new VerifyCodeCommand("email@example.com", "123456"), CancellationToken.None)).IsSuccess.Should().BeTrue(); diff --git a/tests/Orbit.Infrastructure.Tests/Services/DeletionCancellationPersistenceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/DeletionCancellationPersistenceTests.cs index 1710e259f..290b933e8 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/DeletionCancellationPersistenceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/DeletionCancellationPersistenceTests.cs @@ -124,7 +124,7 @@ await CreateJob(restorationContext, stripe, play) var restoredUser = await finalContext.Users.SingleAsync(); if (playSubscription) { - restoredUser.PlayRenewalStoppedByDeletion.Should().BeTrue(); + restoredUser.PlayRenewalStoppedByDeletion.Should().BeFalse(); canceled.Should().BeTrue(); } else @@ -348,7 +348,7 @@ public async Task ExecuteAsync_CancellationOwnershipSurvivesReloadAndOtherProvid restoredUser.StripeRenewalStoppedByDeletion.Should().BeFalse(); stripeCanceled.Should().Be(stripeIndependentlyCanceled); - restoredUser.PlayRenewalStoppedByDeletion.Should().BeTrue(); + restoredUser.PlayRenewalStoppedByDeletion.Should().BeFalse(); await stripe.Received(stripeIndependentlyCanceled ? 0 : 1) .SetSubscriptionCancelAtPeriodEndAsync("sub_paid", false, Arg.Any()); await stripe.Received(stripeIndependentlyCanceled ? 0 : 1) diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleAuthFirstLoginRaceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleAuthFirstLoginRaceTests.cs index ee25379a4..da4100305 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleAuthFirstLoginRaceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleAuthFirstLoginRaceTests.cs @@ -103,7 +103,7 @@ async Task SignInAsync() new GenericRepository(context), new GenericRepository(context), new GenericRepository(context), tokenService, work, Options.Create(new JwtSettings { SecretKey = "unused-test-secret", Issuer = "test", Audience = "test", RefreshExpiryDays = 90 })); - var flow = new GoogleSignInFlow(new AuthUserStore(context), work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs); + var flow = new GoogleSignInFlow(new AuthUserStore(context), work, sessions, new SignupJobDispatcher(jobs, new HttpContextAccessor(), NullLogger.Instance), jobs, new AccountReactivation(Substitute.For>())); if (concurrently) ready.SignalAndWait(TimeSpan.FromSeconds(10)).Should().BeTrue(); var result = await flow.CompleteAsync("Google@Example.com", "Google User", "pt-BR", "friend", @@ -146,9 +146,9 @@ private static async Task SignInAsync(SqliteOrbitDbContextFactory database using var cache = new MemoryCache(new MemoryCacheOptions()); cache.Set("verify:google@example.com", new VerificationEntry("123456", 0, DateTime.UtcNow)); var result = google - ? await new GoogleSignInFlow(users, work, sessions, dispatcher, jobs).CompleteAsync( + ? await new GoogleSignInFlow(users, work, sessions, dispatcher, jobs, new AccountReactivation(Substitute.For>())).CompleteAsync( "google@example.com", "Google User", language, referralCode, null, null, CancellationToken.None) - : await new VerifyCodeCommandHandler(cache, users, work, sessions, dispatcher, jobs).Handle( + : await new VerifyCodeCommandHandler(cache, users, work, sessions, dispatcher, jobs, new AccountReactivation(Substitute.For>())).Handle( new VerifyCodeCommand("google@example.com", "123456", language, referralCode), CancellationToken.None); result.IsSuccess.Should().BeTrue(); result.Value.Token.Should().Be("session-token"); diff --git a/tests/Orbit.Infrastructure.Tests/Services/PlayReactivationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PlayReactivationTests.cs new file mode 100644 index 000000000..975780c5f --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/PlayReactivationTests.cs @@ -0,0 +1,415 @@ +using System.Data.Common; +using FluentAssertions; +using Hangfire; +using Hangfire.Common; +using Hangfire.States; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Auth.Commands; +using Orbit.Application.Auth.Jobs; +using Orbit.Application.Common; +using Orbit.Application.Notifications.Queries; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; +using Orbit.Infrastructure.Tests.Persistence; + +namespace Orbit.Infrastructure.Tests.Services; + +public class PlayReactivationTests +{ + private readonly IPlayBillingService _play = Substitute.For(); + private readonly IBillingService _stripe = Substitute.For(); + private readonly IBackgroundJobClient _jobs = Substitute.For(); + private readonly IOptions _settings = Options.Create(new GooglePlaySettings { ProductId = "orbit_pro" }); + private bool _renewalOff; + private readonly List _noticeJobs = []; + + public PlayReactivationTests() + { + _jobs.Create(Arg.Do(job => + { + if (job.Type == typeof(NotifyPlayRenewalOnReactivationJob)) + _noticeJobs.Add(job); + }), Arg.Any()).Returns("queued-job"); + _play.VerifyAsync("orbit_pro", "play-token", Arg.Any()) + .Returns(_ => new PlaySubscriptionState(true, DateTime.UtcNow.AddDays(20), SubscriptionInterval.Monthly, + true, "orbit_pro", null, null, CanRenew: !_renewalOff)); + _play.CancelAsync("play-token", Arg.Any()) + .Returns(_ => { _renewalOff = true; return Task.CompletedTask; }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SignIn_DeletionOwnedButPlayStillRenewing_DoesNotNotify(bool google) + { + using var database = new SqliteOrbitDbContextFactory(); + var user = User.Create("Returning", "paid@example.com").Value; + user.SetPlaySubscription("play-token", DateTime.UtcNow.AddDays(20)); + user.Deactivate(DateTime.UtcNow.AddDays(27)); + database.Context.Users.Add(user); + await database.Context.SaveChangesAsync(); + _play.CancelAsync("play-token", Arg.Any()) + .Returns(Task.FromException(new BillingProviderException("Play unavailable"))); + using (var context = database.CreateContext()) + { + var cancel = () => CreateCancellationJob(context).ExecuteAsync(user.Id, null, "play-token", true, CancellationToken.None); + await cancel.Should().ThrowAsync(); + (await context.Users.IgnoreQueryFilters().SingleAsync()).PlayRenewalStoppedByDeletion.Should().BeTrue(); + } + _renewalOff.Should().BeFalse(); + + await SignInAsync(database, google); + await RunNoticeJobAsync(database, _noticeJobs.Should().ContainSingle().Subject); + using (var context = database.CreateContext()) + await CreateCancellationJob(context).ExecuteAsync(user.Id, null, "play-token", true, CancellationToken.None); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(0, "Play is still renewing despite deletion ownership"); + (await persisted.Users.SingleAsync()).PlayRenewalStoppedByDeletion.Should().BeFalse(); + } + + [Theory] + [InlineData(false, "en", false)] + [InlineData(true, "en", false)] + [InlineData(false, "pt-BR", false)] + [InlineData(true, "pt-BR", false)] + [InlineData(false, "en", true)] + [InlineData(true, "en", true)] + [InlineData(false, "pt-BR", true)] + [InlineData(true, "pt-BR", true)] + public async Task SignIn_AfterRealCancellation_NotifiesOnceAndClearsOwnership(bool google, string language, bool expired) + { + using var database = new SqliteOrbitDbContextFactory(); + var userId = await CancelForDeletionAsync(database, language); + var expiryAtUtc = DateTime.UtcNow.AddDays(expired ? -1 : 20); + var owed = OwedSubscriptionCancellation.Create(SubscriptionSource.GooglePlay, "play-token"); + using (var context = database.CreateContext()) + { + var user = await context.Users.IgnoreQueryFilters().SingleAsync(); + user.SetPlaySubscription("play-token", expiryAtUtc); + context.OwedSubscriptionCancellations.AddRange(owed, + OwedSubscriptionCancellation.Create(SubscriptionSource.GooglePlay, "unrelated-token"), + OwedSubscriptionCancellation.Create(SubscriptionSource.Stripe, "play-token")); + await context.SaveChangesAsync(); + } + _play.ClearReceivedCalls(); + _play.VerifyAsync("orbit_pro", "play-token", Arg.Any()) + .Returns(new PlaySubscriptionState(!expired, expiryAtUtc, SubscriptionInterval.Monthly, + true, "orbit_pro", null, null, CanRenew: false)); + + await SignInAsync(database, google); + await SignInAsync(database, !google); + using (var delayedContext = database.CreateContext()) + { + await CreateCancellationJob(delayedContext).ExecuteAsync(userId, null, "play-token", true, CancellationToken.None); + await CreateOwedJob(delayedContext).ExecuteAsync(owed.Id, CancellationToken.None); + } + + using var persisted = database.CreateContext(); + var reactivated = await persisted.Users.SingleAsync(); + reactivated.IsDeactivated.Should().BeFalse(); + reactivated.PlayRenewalStoppedByDeletion.Should().BeFalse(); + reactivated.PlanExpiresAt.Should().Be(expiryAtUtc); + reactivated.IsPro.Should().Be(!expired); + _renewalOff.Should().BeTrue(); + _play.ReceivedCalls().Should().BeEmpty("sign-in and stale cancellation retries must not mutate Play renewal"); + (await persisted.Notifications.CountAsync()).Should().Be(0, "the notice must wait for Play verification"); + var job = _noticeJobs.Should().ContainSingle().Subject; + await RunNoticeJobAsync(database, job); + await RunNoticeJobAsync(database, job); + await _play.Received(2).VerifyAsync("orbit_pro", "play-token", Arg.Any()); + var remaining = await persisted.OwedSubscriptionCancellations.ToListAsync(); + remaining.Should().HaveCount(2); + remaining.Should().NotContain(record => record.Id == owed.Id); + var inbox = await new GetNotificationsQueryHandler(new GenericRepository(persisted)) + .Handle(new GetNotificationsQuery(userId), CancellationToken.None); + inbox.Value.UnreadCount.Should().Be(1); + var notice = inbox.Value.Items.Should().ContainSingle().Subject; + notice.IsRead.Should().BeFalse(); + notice.Title.Should().Be(language == "pt-BR" + ? "Sua renovação no Google Play está desativada" : "Your Google Play renewal is off"); + notice.Body.Should().Contain(language == "pt-BR" + ? "Pagamentos e assinaturas > Assinaturas" : "Payments & subscriptions > Subscriptions"); + notice.Body.Should().Contain(language == "pt-BR" ? "Assinar novamente" : "Resubscribe"); + notice.Body.Should().Contain(language == "pt-BR" ? "assine o Pro de novo pelo Orbit" : "subscribe to Pro again in Orbit"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task SignIn_FailedSaveOrCommit_RollsBackFlagAndOwedRemovalWithoutQueuingThenRetries(bool google, bool failCommit) + { + var failure = new ReactivationFailureInterceptor(failCommit); + using var database = new SqliteOrbitDbContextFactory(failure); + await CancelForDeletionAsync(database, "en"); + var owed = OwedSubscriptionCancellation.Create(SubscriptionSource.GooglePlay, "play-token"); + database.Context.OwedSubscriptionCancellations.Add(owed); + await database.Context.SaveChangesAsync(); + failure.Enabled = true; + + var signIn = () => SignInAsync(database, google); + await signIn.Should().ThrowAsync(); + _noticeJobs.Should().BeEmpty(); + + using (var rolledBack = database.CreateContext()) + { + var user = await rolledBack.Users.IgnoreQueryFilters().SingleAsync(); + user.IsDeactivated.Should().BeTrue(); + user.PlayRenewalStoppedByDeletion.Should().BeTrue(); + (await rolledBack.Notifications.CountAsync()).Should().Be(0); + (await rolledBack.OwedSubscriptionCancellations.SingleAsync()).Id.Should().Be(owed.Id); + } + await SignInAsync(database, google); + await RunNoticeJobAsync(database, _noticeJobs.Should().ContainSingle().Subject); + using var persisted = database.CreateContext(); + (await persisted.Users.SingleAsync()).PlayRenewalStoppedByDeletion.Should().BeFalse(); + (await persisted.Notifications.CountAsync()).Should().Be(1); + (await persisted.OwedSubscriptionCancellations.CountAsync()).Should().Be(0); + _renewalOff.Should().BeTrue(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NoticeJob_PlayFailure_CreatesNothingAndRetries(bool google) + { + using var database = new SqliteOrbitDbContextFactory(); + await CancelForDeletionAsync(database, "pt-BR"); + await SignInAsync(database, google); + var job = _noticeJobs.Should().ContainSingle().Subject; + _play.VerifyAsync("orbit_pro", "play-token", Arg.Any()) + .Returns(Task.FromException(new BillingProviderException("Play unavailable"))); + + var execute = () => RunNoticeJobAsync(database, job); + await execute.Should().ThrowAsync(); + + using (var failed = database.CreateContext()) + { + (await failed.Notifications.CountAsync()).Should().Be(0); + (await failed.Users.SingleAsync()).PlayRenewalStoppedByDeletion.Should().BeFalse(); + } + var retry = job.Method.GetCustomAttributes(typeof(AutomaticRetryAttribute), false) + .Should().ContainSingle().Subject.Should().BeOfType().Subject; + retry.Attempts.Should().BeGreaterThan(0); + _play.VerifyAsync("orbit_pro", "play-token", Arg.Any()) + .Returns(new PlaySubscriptionState(true, DateTime.UtcNow.AddDays(20), SubscriptionInterval.Monthly, + true, "orbit_pro", null, null, CanRenew: false)); + + await RunNoticeJobAsync(database, job); + await RunNoticeJobAsync(database, job); + + using var persisted = database.CreateContext(); + var notice = (await persisted.Notifications.ToListAsync()).Should().ContainSingle().Subject; + notice.Title.Should().Be("Sua renovação no Google Play está desativada"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NoticeJob_FailedSaveOrCommit_RollsBackThenRetriesOnce(bool failCommit) + { + var failure = new ReactivationFailureInterceptor(failCommit); + using var database = new SqliteOrbitDbContextFactory(failure); + await CancelForDeletionAsync(database, "en"); + await SignInAsync(database, false); + var job = _noticeJobs.Should().ContainSingle().Subject; + failure.Enabled = true; + + var execute = () => RunNoticeJobAsync(database, job); + await execute.Should().ThrowAsync(); + + using (var failed = database.CreateContext()) + (await failed.Notifications.CountAsync()).Should().Be(0); + await RunNoticeJobAsync(database, job); + await RunNoticeJobAsync(database, job); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task NoticeJob_UnknownPurchase_DoesNotGuessRenewalState() + { + using var database = new SqliteOrbitDbContextFactory(); + await CancelForDeletionAsync(database, "en"); + await SignInAsync(database, false); + _play.VerifyAsync("orbit_pro", "play-token", Arg.Any()) + .Returns((PlaySubscriptionState?)null); + + await RunNoticeJobAsync(database, _noticeJobs.Should().ContainSingle().Subject); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData("deactivated")] + [InlineData("changed-purchase")] + [InlineData("deleted")] + public async Task NoticeJob_AccountChangedBeforeDelivery_DoesNotNotify(string change) + { + using var database = new SqliteOrbitDbContextFactory(); + await CancelForDeletionAsync(database, "en"); + await SignInAsync(database, false); + using (var context = database.CreateContext()) + { + var user = await context.Users.SingleAsync(); + if (change == "deactivated") + user.Deactivate(DateTime.UtcNow.AddDays(27)); + else if (change == "changed-purchase") + user.SetPlaySubscription("replacement-token", DateTime.UtcNow.AddDays(20)); + else + context.Users.Remove(user); + await context.SaveChangesAsync(); + } + + await RunNoticeJobAsync(database, _noticeJobs.Should().ContainSingle().Subject); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task NoticeJob_DismissedNotice_IsNotRecreatedOnReplay() + { + using var database = new SqliteOrbitDbContextFactory(); + await CancelForDeletionAsync(database, "en"); + await SignInAsync(database, false); + var job = _noticeJobs.Should().ContainSingle().Subject; + await RunNoticeJobAsync(database, job); + using (var context = database.CreateContext()) + { + (await context.Notifications.SingleAsync()).SoftDelete(); + await context.SaveChangesAsync(); + } + + await RunNoticeJobAsync(database, job); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(0); + (await persisted.Notifications.IgnoreQueryFilters().CountAsync()).Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SignIn_IndependentlyCanceledPlay_DoesNotNotifyOrRemoveOwedRecords(bool google) + { + using var database = new SqliteOrbitDbContextFactory(); + var user = User.Create("Returning", "paid@example.com").Value; + user.SetPlaySubscription("play-token", DateTime.UtcNow.AddDays(20)); + user.Deactivate(DateTime.UtcNow.AddDays(27)); + database.Context.Users.Add(user); + database.Context.OwedSubscriptionCancellations.Add( + OwedSubscriptionCancellation.Create(SubscriptionSource.GooglePlay, "play-token")); + await database.Context.SaveChangesAsync(); + + await SignInAsync(database, google); + + using var persisted = database.CreateContext(); + (await persisted.Notifications.CountAsync()).Should().Be(0); + (await persisted.OwedSubscriptionCancellations.CountAsync()).Should().Be(1); + _play.ReceivedCalls().Should().BeEmpty(); + _noticeJobs.Should().BeEmpty(); + } + + private async Task CancelForDeletionAsync(SqliteOrbitDbContextFactory database, string language) + { + var user = User.Create("Returning", "paid@example.com").Value; + user.SetLanguage(language); + user.SetPlaySubscription("play-token", DateTime.UtcNow.AddDays(20)); + user.Deactivate(DateTime.UtcNow.AddDays(27)); + database.Context.Users.Add(user); + await database.Context.SaveChangesAsync(); + using var context = database.CreateContext(); + await CreateCancellationJob(context).ExecuteAsync(user.Id, null, "play-token", true, CancellationToken.None); + _renewalOff.Should().BeTrue(); + (await context.Users.IgnoreQueryFilters().SingleAsync()).PlayRenewalStoppedByDeletion.Should().BeTrue(); + return user.Id; + } + + private async Task SignInAsync(SqliteOrbitDbContextFactory database, bool google) + { + using var context = database.CreateContext(); + var work = new UnitOfWork(context, new DatabaseConnectionSettings()); + var sessions = Substitute.For(); + sessions.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(new SessionTokens("access", "refresh"))); + var users = new AuthUserStore(context); + var reactivation = new AccountReactivation(new GenericRepository(context)); + var dispatcher = new SignupJobDispatcher(_jobs, new HttpContextAccessor(), NullLogger.Instance); + using var cache = new MemoryCache(new MemoryCacheOptions()); + cache.Set("verify:paid@example.com", new VerificationEntry("123456", 0, DateTime.UtcNow)); + var result = google + ? await new GoogleSignInFlow(users, work, sessions, dispatcher, _jobs, reactivation) + .CompleteAsync("paid@example.com", "Returning", "en", null, null, null, CancellationToken.None) + : await new VerifyCodeCommandHandler(cache, users, work, sessions, dispatcher, _jobs, reactivation) + .Handle(new VerifyCodeCommand("paid@example.com", "123456"), CancellationToken.None); + result.IsSuccess.Should().BeTrue(); + } + + private CancelSubscriptionOnDeletionJob CreateCancellationJob(OrbitDbContext context) => + new(new GenericRepository(context), new UnitOfWork(context, new DatabaseConnectionSettings()), + _stripe, _play, _settings, CreateOwedJob(context)); + + private OwedSubscriptionCancellationJob CreateOwedJob(OrbitDbContext context) => + new(new GenericRepository(context), + new UnitOfWork(context, new DatabaseConnectionSettings()), _stripe, _play, _settings, + NullLogger.Instance, TimeProvider.System); + + private async Task RunNoticeJobAsync(SqliteOrbitDbContextFactory database, Job job) + { + using var context = database.CreateContext(); + var play = Substitute.For(); + play.VerifyAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(async call => + { + context.Database.CurrentTransaction.Should().BeNull("Play verification must run outside a database transaction"); + return await _play.VerifyAsync(call.ArgAt(0), call.ArgAt(1), call.Arg()); + }); + var noticeJob = new NotifyPlayRenewalOnReactivationJob(new GenericRepository(context), + new GenericRepository(context), new UnitOfWork(context, new DatabaseConnectionSettings()), play, _settings); + var execution = job.Method.Invoke(noticeJob, job.Args.ToArray()); + await execution.Should().BeAssignableTo().Subject; + } + + private sealed class ReactivationFailureInterceptor(bool failCommit) : DbTransactionInterceptor, ISaveChangesInterceptor + { + public bool Enabled { get; set; } + + public ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Enabled && !failCommit) + Fail(); + return ValueTask.FromResult(result); + } + + public override ValueTask TransactionCommittingAsync(DbTransaction transaction, + TransactionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Enabled && failCommit) + Fail(); + return ValueTask.FromResult(result); + } + + private void Fail() + { + Enabled = false; + throw new DbUpdateException("Reactivation persistence failed"); + } + } +}