mirror of
https://github.com/bitwarden/server
synced 2026-01-09 20:13:24 +00:00
Merge remote-tracking branch 'origin/main' into xunit-v3-full-upgrade
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Billing\Billing.csproj" />
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -17,6 +17,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Testing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Neovolve.Logging.Xunit;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
@@ -24,7 +25,7 @@ using Transaction = Bit.Core.Entities.Transaction;
|
||||
|
||||
namespace Bit.Billing.Test.Controllers;
|
||||
|
||||
public class PayPalControllerTests
|
||||
public class PayPalControllerTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
private readonly ILogger<PayPalController> _logger;
|
||||
private readonly FakeLogCollector _fakeLogCollector;
|
||||
@@ -126,8 +127,6 @@ public class PayPalControllerTests
|
||||
PayPal = { WebhookKey = _defaultWebhookKey }
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.UnsupportedTransactionType);
|
||||
|
||||
var controller = ConfigureControllerContextWith(_defaultWebhookKey, ipnBody);
|
||||
@@ -151,8 +150,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment);
|
||||
|
||||
var controller = ConfigureControllerContextWith(_defaultWebhookKey, ipnBody);
|
||||
@@ -176,8 +173,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.RefundMissingParentTransaction);
|
||||
|
||||
var controller = ConfigureControllerContextWith(_defaultWebhookKey, ipnBody);
|
||||
@@ -201,8 +196,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.ECheckPayment);
|
||||
|
||||
var controller = ConfigureControllerContextWith(_defaultWebhookKey, ipnBody);
|
||||
@@ -226,8 +219,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.NonUSDPayment);
|
||||
|
||||
var controller = ConfigureControllerContextWith(_defaultWebhookKey, ipnBody);
|
||||
@@ -251,8 +242,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment);
|
||||
|
||||
_transactionRepository.GetByGatewayIdAsync(
|
||||
@@ -280,8 +269,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment);
|
||||
|
||||
_transactionRepository.GetByGatewayIdAsync(
|
||||
@@ -399,8 +386,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund);
|
||||
|
||||
_transactionRepository.GetByGatewayIdAsync(
|
||||
@@ -432,8 +417,6 @@ public class PayPalControllerTests
|
||||
}
|
||||
});
|
||||
|
||||
var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e");
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund);
|
||||
|
||||
_transactionRepository.GetByGatewayIdAsync(
|
||||
|
||||
641
test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs
Normal file
641
test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs
Normal file
@@ -0,0 +1,641 @@
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Billing.Test.Jobs;
|
||||
|
||||
public class ReconcileAdditionalStorageJobTests
|
||||
{
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly ILogger<ReconcileAdditionalStorageJob> _logger;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ReconcileAdditionalStorageJob _sut;
|
||||
|
||||
public ReconcileAdditionalStorageJobTests()
|
||||
{
|
||||
_stripeFacade = Substitute.For<IStripeFacade>();
|
||||
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService);
|
||||
}
|
||||
|
||||
#region Feature Flag Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_FeatureFlagDisabled_SkipsProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
_stripeFacade.DidNotReceiveWithAnyArgs().ListSubscriptionsAutoPagingAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_FeatureFlagEnabled_ProcessesSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)
|
||||
.Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode)
|
||||
.Returns(false);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Empty<Subscription>());
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
_stripeFacade.Received(3).ListSubscriptionsAutoPagingAsync(
|
||||
Arg.Is<SubscriptionListOptions>(o => o.Status == "active"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dry Run Mode Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_DryRunMode_DoesNotUpdateSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Price ID Processing Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ProcessesAllThreePriceIds()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Empty<Subscription>());
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
|
||||
Arg.Is<SubscriptionListOptions>(o => o.Price == "storage-gb-monthly"));
|
||||
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
|
||||
Arg.Is<SubscriptionListOptions>(o => o.Price == "storage-gb-annually"));
|
||||
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
|
||||
Arg.Is<SubscriptionListOptions>(o => o.Price == "personal-storage-gb-annually"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Already Processed Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionAlreadyProcessed_SkipsUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionWithInvalidProcessedDate_ProcessesSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date"
|
||||
};
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionWithoutMetadata_ProcessesSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Quantity Reduction Logic Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_QuantityGreaterThan4_ReducesBy4()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items.Count == 1 &&
|
||||
o.Items[0].Quantity == 6 &&
|
||||
o.Items[0].Deleted != true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_QuantityEquals4_DeletesItem()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items.Count == 1 &&
|
||||
o.Items[0].Deleted == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_QuantityLessThan4_DeletesItem()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items.Count == 1 &&
|
||||
o.Items[0].Deleted == true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Options Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_UpdateOptions_SetsProrationBehaviorToCreateProrations()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_UpdateOptions_SetsReconciledMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.StorageReconciled2025) &&
|
||||
!string.IsNullOrEmpty(o.Metadata[StripeConstants.MetadataKeys.StorageReconciled2025])));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscription Filtering Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionWithNoItems_SkipsUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = null
|
||||
};
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionWithDifferentPriceId_SkipsUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "different-price-id", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_NullSubscription_SkipsProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create<Subscription>(null!));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Subscriptions Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_MultipleSubscriptions_ProcessesAll()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(callInfo => callInfo.Arg<string>() switch
|
||||
{
|
||||
"sub_1" => subscription1,
|
||||
"sub_2" => subscription2,
|
||||
"sub_3" => subscription3,
|
||||
_ => null
|
||||
});
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_MixedSubscriptionsWithProcessed_OnlyProcessesUnprocessed()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var processedMetadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(callInfo => callInfo.Arg<string>() switch
|
||||
{
|
||||
"sub_1" => subscription1,
|
||||
"sub_3" => subscription3,
|
||||
_ => null
|
||||
});
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_UpdateFails_ContinuesProcessingOthers()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
|
||||
_stripeFacade.UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription1);
|
||||
_stripeFacade.UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Throws(new Exception("Stripe API error"));
|
||||
_stripeFacade.UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription3);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_UpdateFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Throws(new Exception("Stripe API error"));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
_logger.Received().Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancellation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_CancellationRequested_LogsWarningAndExits()
|
||||
{
|
||||
// Arrange
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel(); // Cancel immediately
|
||||
var context = CreateJobExecutionContext(cts.Token);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - Should not process any subscriptions due to immediate cancellation
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null);
|
||||
_logger.Received().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var context = Substitute.For<IJobExecutionContext>();
|
||||
context.CancellationToken.Returns(cancellationToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Subscription CreateSubscription(
|
||||
string id,
|
||||
string priceId,
|
||||
long? quantity = null,
|
||||
Dictionary<string, string>? metadata = null)
|
||||
{
|
||||
var price = new Price { Id = priceId };
|
||||
var item = new SubscriptionItem
|
||||
{
|
||||
Id = $"si_{id}",
|
||||
Price = price,
|
||||
Quantity = quantity ?? 0
|
||||
};
|
||||
|
||||
return new Subscription
|
||||
{
|
||||
Id = id,
|
||||
Metadata = metadata,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem> { item }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
internal static class AsyncEnumerable
|
||||
{
|
||||
public static async IAsyncEnumerable<T> Create<T>(params T[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<T> Empty<T>()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal file
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Billing.Test.Jobs;
|
||||
|
||||
public class SubscriptionCancellationJobTests
|
||||
{
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly SubscriptionCancellationJob _sut;
|
||||
|
||||
public SubscriptionCancellationJobTests()
|
||||
{
|
||||
_stripeFacade = Substitute.For<IStripeFacade>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For<ILogger<SubscriptionCancellationJob>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_OrganizationIsNull_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns((Organization)null);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_OrganizationIsEnabled_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = true
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionStatusIsNotUnpaid_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_BillingReasonIsInvalid_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "manual"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ValidConditions_CancelsSubscriptionAndVoidsInvoices()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_1" },
|
||||
new Invoice { Id = "inv_2" }
|
||||
],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_1");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithSubscriptionCreateBillingReason_CancelsSubscription()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_create"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_NoOpenInvoices_CancelsSubscriptionOnly()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithPagination_VoidsAllInvoices()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// First page of invoices
|
||||
var firstPage = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_1" },
|
||||
new Invoice { Id = "inv_2" }
|
||||
],
|
||||
HasMore = true
|
||||
};
|
||||
|
||||
// Second page of invoices
|
||||
var secondPage = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_3" },
|
||||
new Invoice { Id = "inv_4" }
|
||||
],
|
||||
HasMore = false
|
||||
};
|
||||
|
||||
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == null))
|
||||
.Returns(firstPage);
|
||||
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == "inv_2"))
|
||||
.Returns(secondPage);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_1");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_2");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_3");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_4");
|
||||
await _stripeFacade.Received(2).ListInvoices(Arg.Any<InvoiceListOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ListInvoicesCalledWithCorrectOptions()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")));
|
||||
await _stripeFacade.Received(1).ListInvoices(Arg.Is<InvoiceListOptions>(o =>
|
||||
o.Status == "open" &&
|
||||
o.Subscription == subscriptionId &&
|
||||
o.Limit == 100));
|
||||
}
|
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId)
|
||||
{
|
||||
var context = Substitute.For<IJobExecutionContext>();
|
||||
var jobDataMap = new JobDataMap
|
||||
{
|
||||
{ "subscriptionId", subscriptionId },
|
||||
{ "organizationId", organizationId.ToString() }
|
||||
};
|
||||
context.MergedJobDataMap.Returns(jobDataMap);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Test.Billing.Mocks;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
@@ -237,7 +237,7 @@ public class ProviderEventServiceTests
|
||||
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
_pricingClient.GetPlanOrThrow(providerPlan.PlanType).Returns(StaticStore.GetPlan(providerPlan.PlanType));
|
||||
_pricingClient.GetPlanOrThrow(providerPlan.PlanType).Returns(MockPlans.Get(providerPlan.PlanType));
|
||||
}
|
||||
|
||||
_providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans);
|
||||
@@ -246,8 +246,8 @@ public class ProviderEventServiceTests
|
||||
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
|
||||
|
||||
// Assert
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
var teamsPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||
var enterprisePlan = MockPlans.Get(PlanType.EnterpriseMonthly);
|
||||
|
||||
await _providerInvoiceItemRepository.Received(1).CreateAsync(Arg.Is<ProviderInvoiceItem>(
|
||||
options =>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.Billing.Mocks.Plans;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NSubstitute;
|
||||
@@ -21,6 +20,8 @@ using Quartz;
|
||||
using Stripe;
|
||||
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;
|
||||
|
||||
@@ -126,79 +127,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
Arg.Is<ITrigger>(t => t.Key.Name == $"cancel-trigger-{subscriptionId}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_UnpaidProviderSubscription_WithManualSuspensionViaMetadata_DisablesProviderAndSchedulesCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_test123";
|
||||
|
||||
var previousSubscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["suspend_provider"] = null // This is the key part - metadata exists, but value is null
|
||||
}
|
||||
};
|
||||
|
||||
var currentSubscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["providerId"] = providerId.ToString(),
|
||||
["suspend_provider"] = "true" // Now has a value, indicating manual suspension
|
||||
},
|
||||
TestClock = null
|
||||
};
|
||||
|
||||
var parsedEvent = new Event
|
||||
{
|
||||
Id = "evt_test123",
|
||||
Type = HandledStripeWebhook.SubscriptionUpdated,
|
||||
Data = new EventData
|
||||
{
|
||||
Object = currentSubscription,
|
||||
PreviousAttributes = JObject.FromObject(previousSubscription)
|
||||
}
|
||||
};
|
||||
|
||||
var provider = new Provider { Id = providerId, Enabled = true };
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
|
||||
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
_providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
|
||||
// Verify that UpdateSubscription was called with both CancelAt and the new metadata
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
subscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CancelAt.HasValue &&
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
|
||||
options.Metadata != null &&
|
||||
options.Metadata.ContainsKey("suspended_provider_via_webhook_at")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation()
|
||||
@@ -243,7 +171,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
|
||||
var provider = new Provider { Id = providerId, Enabled = true };
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
|
||||
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
@@ -256,13 +183,12 @@ public class SubscriptionUpdatedHandlerTests
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
|
||||
// Verify that UpdateSubscription was called with CancelAt but WITHOUT suspension metadata
|
||||
// Verify that UpdateSubscription was called with CancelAt
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
subscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CancelAt.HasValue &&
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
|
||||
(options.Metadata == null || !options.Metadata.ContainsKey("suspended_provider_via_webhook_at"))));
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,9 +232,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -353,9 +276,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -401,9 +321,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -416,48 +333,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnpaidProviderSubscription_WhenFeatureFlagDisabled_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_123";
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
};
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
|
||||
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_DoesNothing()
|
||||
{
|
||||
@@ -489,9 +364,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns((Provider)null);
|
||||
|
||||
@@ -530,6 +402,75 @@ public class SubscriptionUpdatedHandlerTests
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
LegacyYear = null,
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>())
|
||||
.Returns(new StripeList<Invoice> { Data = new List<Invoice>() });
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(1)
|
||||
.DisablePremiumAsync(userId, currentPeriodEnd);
|
||||
await _stripeFacade.Received(1)
|
||||
.CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1)
|
||||
.ListInvoices(Arg.Is<InvoiceListOptions>(o =>
|
||||
o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_IncompleteExpiredUserSubscription_DisablesPremiumAndCancelsSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_123";
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.IncompleteExpired,
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
LegacyYear = null,
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
@@ -695,7 +636,7 @@ public class SubscriptionUpdatedHandlerTests
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
||||
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
|
||||
Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -729,7 +670,7 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } }
|
||||
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } }
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -777,8 +718,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeFacade
|
||||
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(newSubscription);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -800,9 +739,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
.Received(1)
|
||||
.UpdateSubscription(newSubscription.Id,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options => options.CancelAtPeriodEnd == false));
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -823,8 +759,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -843,9 +777,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -866,8 +797,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -886,9 +815,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -909,8 +835,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -929,9 +853,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -953,8 +874,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -975,9 +894,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -997,8 +913,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.ReturnsNull();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1019,9 +933,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceive()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1040,8 +951,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1062,9 +971,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceive()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent)
|
||||
@@ -1098,6 +1004,134 @@ public class SubscriptionUpdatedHandlerTests
|
||||
return (providerId, newSubscription, provider, parsedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_IncompleteUserSubscriptionWithOpenInvoice_CancelsSubscriptionAndDisablesPremium()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_123";
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
var openInvoice = new Invoice
|
||||
{
|
||||
Id = "inv_123",
|
||||
Status = StripeInvoiceStatus.Open
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Incomplete,
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
|
||||
LatestInvoice = openInvoice,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
LegacyYear = null,
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>())
|
||||
.Returns(new StripeList<Invoice> { Data = new List<Invoice> { openInvoice } });
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(1)
|
||||
.DisablePremiumAsync(userId, currentPeriodEnd);
|
||||
await _stripeFacade.Received(1)
|
||||
.CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1)
|
||||
.ListInvoices(Arg.Is<InvoiceListOptions>(o =>
|
||||
o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId));
|
||||
await _stripeFacade.Received(1)
|
||||
.VoidInvoice(openInvoice.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_IncompleteUserSubscriptionWithoutOpenInvoice_DoesNotCancelSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_123";
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
var paidInvoice = new Invoice
|
||||
{
|
||||
Id = "inv_123",
|
||||
Status = StripeInvoiceStatus.Paid
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Incomplete,
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
|
||||
LatestInvoice = paidInvoice,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
LegacyYear = null,
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { premiumPlan });
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _userService.DidNotReceive()
|
||||
.DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
|
||||
await _stripeFacade.DidNotReceive()
|
||||
.CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.DidNotReceive()
|
||||
.ListInvoices(Arg.Any<InvoiceListOptions>());
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetNonActiveSubscriptions()
|
||||
{
|
||||
return new List<object[]>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user