1
0
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:
Justin Baur
2025-12-12 16:00:18 -05:00
523 changed files with 34986 additions and 7245 deletions

View File

@@ -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>

View File

@@ -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(

View 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;
}
}

View 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;
}
}

View File

@@ -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 =>

View File

@@ -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