The core infrastructure backend (API, database, Docker, etc).
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

364 lines
14 KiB

using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
using Stripe.Checkout;
using Xunit;
using Event = Stripe.Event;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
namespace Bit.Billing.Test.Services;
public class CheckoutSessionCompletedHandlerTests
{
private static readonly Guid _userId = Guid.NewGuid();
private static readonly string _sessionId = "cs_test_123";
private static readonly string _customerId = "cus_test_123";
private static readonly string _subscriptionId = "sub_test_123";
private static readonly string _premiumSeatPriceId = "price_premium_seat";
private static readonly string _paymentMethodId = "pm_test_123";
private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "checkout.session.completed" };
private readonly ILogger<CheckoutSessionCompletedHandler> _logger;
private readonly IStripeAdapter _stripeAdapter;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IUserRepository _userRepository;
private readonly IPricingClient _pricingClient;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
private readonly IStripeEventService _stripeEventService;
private readonly CheckoutSessionCompletedHandler _sut;
private readonly PremiumPlan _premiumPlan = new()
{
Name = "Premium",
Available = true,
Seat = new PremiumPurchasable { StripePriceId = _premiumSeatPriceId, Price = 10m, Provided = 1 },
Storage = new PremiumPurchasable { StripePriceId = "price_storage", Price = 4m, Provided = 1 }
};
public CheckoutSessionCompletedHandlerTests()
{
_logger = Substitute.For<ILogger<CheckoutSessionCompletedHandler>>();
_stripeAdapter = Substitute.For<IStripeAdapter>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_userRepository = Substitute.For<IUserRepository>();
_pricingClient = Substitute.For<IPricingClient>();
_pushNotificationAdapter = Substitute.For<IPushNotificationAdapter>();
_stripeEventService = Substitute.For<IStripeEventService>();
_sut = new CheckoutSessionCompletedHandler(
_stripeEventService,
_stripeEventUtilityService,
_stripeAdapter,
_userRepository,
_pricingClient,
_pushNotificationAdapter,
_logger);
}
[Fact]
public async Task HandleAsync_SessionHasNoSubscription_LogsErrorAndReturns()
{
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session { Id = _sessionId });
await _sut.HandleAsync(_mockEvent);
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(null!);
}
[Fact]
public async Task HandleAsync_NoUserIdInMetadata_LogsErrorAndReturns()
{
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
SubscriptionId = _subscriptionId,
Subscription = new Subscription { Id = _subscriptionId, Metadata = [] }
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, null, null));
await _sut.HandleAsync(_mockEvent);
await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(null!);
}
[Fact]
public async Task HandleAsync_UserNotFound_LogsErrorAndReturns()
{
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
SubscriptionId = _subscriptionId,
Subscription = new Subscription { Id = _subscriptionId, Metadata = [] }
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns((User)null!);
await _sut.HandleAsync(_mockEvent);
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(null!);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyPremiumStatusChangedAsync(null!);
}
[Fact]
public async Task HandleAsync_SubscriptionIsNotPremium_LogsErrorAndReturns()
{
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = [],
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Price = new Price { Id = "price_other_product" } }]
}
};
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session { Id = _sessionId, SubscriptionId = _subscriptionId, Subscription = subscription });
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(new User { Id = _userId });
_pricingClient.GetAvailablePremiumPlan().Returns(_premiumPlan);
await _sut.HandleAsync(_mockEvent);
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(null!);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyPremiumStatusChangedAsync(null!);
}
[Fact]
public async Task HandleAsync_ValidSession_SetsPremiumUpdatesPaymentMethodAndNotifiesUser()
{
var periodEnd = DateTime.UtcNow.AddYears(1);
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = [],
DefaultPaymentMethodId = _paymentMethodId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = _premiumSeatPriceId },
CurrentPeriodEnd = periodEnd
}
]
}
};
var user = new User { Id = _userId, Premium = false };
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
CustomerId = _customerId,
SubscriptionId = _subscriptionId,
Subscription = subscription
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(_premiumPlan);
await _sut.HandleAsync(_mockEvent);
Assert.True(user.Premium);
Assert.Equal(_subscriptionId, user.GatewaySubscriptionId);
Assert.Equal(GatewayType.Stripe, user.Gateway);
Assert.Equal(periodEnd, user.PremiumExpirationDate);
Assert.Equal((short)_premiumPlan.Storage.Provided, user.MaxStorageGb);
Assert.NotNull(user.LicenseKey);
await _stripeEventService.Received(1).GetCheckoutSession(
_mockEvent,
true,
Arg.Is<List<string>?>(l => l != null && l.Contains("subscription")));
await _stripeAdapter.Received(1).UpdateCustomerAsync(
_customerId,
Arg.Is<CustomerUpdateOptions>(opts =>
opts.InvoiceSettings.DefaultPaymentMethod == _paymentMethodId));
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
_subscriptionId,
Arg.Is<SubscriptionUpdateOptions>(opts => opts.DefaultPaymentMethod == string.Empty));
await _userRepository.Received(1).ReplaceAsync(user);
await _pushNotificationAdapter.Received(1).NotifyPremiumStatusChangedAsync(user);
}
[Fact]
public async Task HandleAsync_ValidSession_WithoutSubscriptionDefaultPaymentMethod_SkipsPaymentMethodUpdate()
{
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = [],
DefaultPaymentMethodId = null,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = _premiumSeatPriceId },
CurrentPeriodEnd = DateTime.UtcNow.AddYears(1)
}
]
}
};
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
CustomerId = _customerId,
SubscriptionId = _subscriptionId,
Subscription = subscription
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(new User { Id = _userId });
_pricingClient.GetAvailablePremiumPlan().Returns(_premiumPlan);
await _sut.HandleAsync(_mockEvent);
await _stripeAdapter.DidNotReceiveWithAnyArgs().UpdateCustomerAsync(null!, null!);
await _stripeAdapter.DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync(null!, null!);
}
[Fact]
public async Task HandleAsync_UserAlreadyHasLicenseKey_PreservesExistingLicenseKey()
{
var existingLicenseKey = "existing-license-key-12";
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = [],
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = _premiumSeatPriceId },
CurrentPeriodEnd = DateTime.UtcNow.AddYears(1)
}
]
}
};
var user = new User { Id = _userId, LicenseKey = existingLicenseKey };
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
CustomerId = _customerId,
SubscriptionId = _subscriptionId,
Subscription = subscription
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(_premiumPlan);
await _sut.HandleAsync(_mockEvent);
Assert.Equal(existingLicenseKey, user.LicenseKey);
await _userRepository.Received(1).ReplaceAsync(user);
}
[Fact]
public async Task HandleAsync_UserAlreadyPremium_LogsErrorAndReturns()
{
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = []
};
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
CustomerId = _customerId,
SubscriptionId = _subscriptionId,
Subscription = subscription
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(new User { Id = _userId, Premium = true });
await _sut.HandleAsync(_mockEvent);
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(null!);
await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyPremiumStatusChangedAsync(null!);
await _stripeAdapter.DidNotReceiveWithAnyArgs().UpdateCustomerAsync(null!, null!);
await _stripeAdapter.DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync(null!, null!);
}
[Fact]
public async Task HandleAsync_UpdateCustomerAsyncThrows_PropagatesException()
{
var subscription = new Subscription
{
Id = _subscriptionId,
Metadata = [],
DefaultPaymentMethodId = _paymentMethodId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Price = new Price { Id = _premiumSeatPriceId },
CurrentPeriodEnd = DateTime.UtcNow.AddYears(1)
}
]
}
};
_stripeEventService
.GetCheckoutSession(_mockEvent, true, Arg.Any<List<string>?>())
.Returns(new Session
{
Id = _sessionId,
CustomerId = _customerId,
SubscriptionId = _subscriptionId,
Subscription = subscription
});
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(new User { Id = _userId });
_pricingClient.GetAvailablePremiumPlan().Returns(_premiumPlan);
_stripeAdapter
.UpdateCustomerAsync(_customerId, Arg.Any<CustomerUpdateOptions>())
.ThrowsAsync(new StripeException("Stripe API unavailable"));
await Assert.ThrowsAsync<StripeException>(() => _sut.HandleAsync(_mockEvent));
// User is saved before UpdateDefaultPaymentMethodAsync is called
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
await _stripeAdapter.DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync(null!, null!);
}
}