diff --git a/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs b/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs index 07eaa237..09676692 100644 --- a/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs +++ b/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs @@ -1,6 +1,8 @@ using MediatR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -11,7 +13,7 @@ namespace Orbit.Application.Subscriptions.Commands; -public record HandleWebhookCommand(string Json, string Signature) : IRequest; +public record HandleWebhookCommand(string Json, string Signature) : IRequest, IConcurrencyRetryable; public partial class HandleWebhookCommandHandler( IGenericRepository userRepository, @@ -50,12 +52,6 @@ public async Task Handle(HandleWebhookCommand request, CancellationToken LogStripeEventType(logger, stripeEvent.Type, stripeEvent.Id); - if (await processedEventRepository.AnyAsync(e => e.EventId == stripeEvent.Id, cancellationToken)) - { - LogDuplicateEvent(logger, stripeEvent.Id); - return Result.Success(); - } - await processedEventRepository.AddAsync(ProcessedStripeEvent.Create(stripeEvent.Id), cancellationToken); try @@ -89,7 +85,7 @@ public async Task Handle(HandleWebhookCommand request, CancellationToken LogErrorProcessingStripeEvent(logger, ex, stripeEvent.Type); return Result.Failure(ErrorMessages.WebhookStripeApiError); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException and not DbUpdateConcurrencyException) { LogErrorProcessingStripeEvent(logger, ex, stripeEvent.Type); return Result.Failure(ErrorMessages.WebhookProcessingFailed); diff --git a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs index 290c789a..d3092710 100644 --- a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs @@ -3,13 +3,16 @@ using System.Security.Cryptography; using System.Text; using FluentAssertions; +using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Application.Subscriptions.Commands; +using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; using Stripe; @@ -360,28 +363,34 @@ public async Task Handle_UnknownEventType_ReturnsSuccess() } [Fact] - public async Task Handle_DuplicateEventId_SkipsProcessingWithoutSaving() + public async Task Handle_ReservesProcessedEventViaConstraint_WithoutCheckThenInsertPreCheck() { - _processedRepo.AnyAsync( - Arg.Any>>(), + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), Arg.Any()) - .Returns(true); + .Returns(user); + + var subscription = CreateMockSubscription("sub_test"); + _subscriptionService.GetAsync("sub_test", Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(subscription); var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson( UserId.ToString(), "sub_test", "cus_test")); - var command = new HandleWebhookCommand(json, signature); - var result = await _handler.Handle(command, CancellationToken.None); + var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); result.IsSuccess.Should().BeTrue(); - await _userRepo.DidNotReceive().FindOneTrackedIgnoringFiltersAsync( - Arg.Any>>(), + await _processedRepo.DidNotReceive().AnyAsync( + Arg.Any>>(), Arg.Any()); - await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + await _processedRepo.Received(1).AddAsync( + Arg.Any(), Arg.Any()); } [Fact] - public async Task Handle_ConcurrentDuplicate_SaveConflict_ReturnsSuccess() + public async Task Handle_DuplicateEvent_UniqueViolationOnSave_IsIdempotentSuccess() { var user = User.Create("Thomas", "test@example.com").Value; _userRepo.FindOneTrackedIgnoringFiltersAsync( @@ -400,10 +409,72 @@ public async Task Handle_ConcurrentDuplicate_SaveConflict_ReturnsSuccess() var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson( UserId.ToString(), "sub_test", "cus_test")); + var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Command_IsMarkedConcurrencyRetryable() => + typeof(HandleWebhookCommand).Should().BeAssignableTo(); + + [Fact] + public async Task Handle_SaveThrowsConcurrencyConflict_PropagatesInsteadOfSwallowingAsFailure() + { + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(user); + + var subscription = CreateMockSubscription("sub_test"); + _subscriptionService.GetAsync("sub_test", Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(subscription); + + _unitOfWork.SaveChangesAsync(Arg.Any()) + .ThrowsAsync(new DbUpdateConcurrencyException("stale xmin token")); + + var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson( + UserId.ToString(), "sub_test", "cus_test")); + + var act = () => _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_ThroughRetryBehavior_ConcurrencyConflictThenSuccess_UpgradesProAndRetries() + { + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(user); + + var subscription = CreateMockSubscription("sub_test"); + _subscriptionService.GetAsync("sub_test", Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(subscription); + + var saveAttempts = 0; + _unitOfWork.SaveChangesAsync(Arg.Any()) + .Returns(_ => saveAttempts++ == 0 + ? throw new DbUpdateConcurrencyException("stale xmin token") + : 1); + + var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson( + UserId.ToString(), "sub_test", "cus_test")); var command = new HandleWebhookCommand(json, signature); - var result = await _handler.Handle(command, CancellationToken.None); + var behavior = new ConcurrencyRetryBehavior(_unitOfWork); + RequestHandlerDelegate next = cancellationToken => _handler.Handle(command, cancellationToken); + + var result = await behavior.Handle(command, next, CancellationToken.None); result.IsSuccess.Should().BeTrue(); + saveAttempts.Should().Be(2); + user.IsPro.Should().BeTrue(); + _unitOfWork.Received(1).ResetTracking(); } [Fact]