mirror of
https://github.com/bitwarden/server
synced 2026-01-14 22:43:19 +00:00
* [PM-30460] update storage job to also update database max storage * dry run logs * more logging fixes and pr feedback, forgot sql scripts * claude feedback * pr feedback, redesign of entity id reverse lookup * claude feedback
1451 lines
57 KiB
C#
1451 lines
57 KiB
C#
using Bit.Billing.Jobs;
|
|
using Bit.Billing.Services;
|
|
using Bit.Core;
|
|
using Bit.Core.Billing.Constants;
|
|
using Bit.Core.Repositories;
|
|
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 IUserRepository _userRepository;
|
|
private readonly IOrganizationRepository _organizationRepository;
|
|
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
|
private readonly ReconcileAdditionalStorageJob _sut;
|
|
|
|
public ReconcileAdditionalStorageJobTests()
|
|
{
|
|
_stripeFacade = Substitute.For<IStripeFacade>();
|
|
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
|
|
_featureService = Substitute.For<IFeatureService>();
|
|
_userRepository = Substitute.For<IUserRepository>();
|
|
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
|
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
|
|
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, null));
|
|
|
|
_sut = new ReconcileAdditionalStorageJob(
|
|
_stripeFacade,
|
|
_logger,
|
|
_featureService,
|
|
_userRepository,
|
|
_organizationRepository,
|
|
_stripeEventUtilityService);
|
|
}
|
|
|
|
#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.Limit == 100));
|
|
}
|
|
|
|
#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_DryRunMode_DoesNotUpdateDatabase()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON
|
|
|
|
// Create a personal subscription that would normally trigger a database update
|
|
var userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
|
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
|
|
|
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
|
.Returns(AsyncEnumerable.Create(subscription));
|
|
|
|
// Mock GetIdsFromMetadata to return userId
|
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
// Act
|
|
await _sut.Execute(context);
|
|
|
|
// Assert - Verify database repositories are never called
|
|
await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
|
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
|
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
|
await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
|
}
|
|
|
|
[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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_LiveMode_PersonalSubscription_UpdatesUserDatabase()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
// Setup user
|
|
var userId = Guid.NewGuid();
|
|
var user = new Bit.Core.Entities.User
|
|
{
|
|
Id = userId,
|
|
Email = "test@example.com",
|
|
GatewaySubscriptionId = "sub_personal",
|
|
MaxStorageGb = 15 // Old value
|
|
};
|
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
|
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
|
|
|
// Create personal subscription with premium seat + 10 GB storage (will be reduced to 6 GB)
|
|
var subscription = CreateSubscriptionWithMultipleItems("sub_personal",
|
|
[("premium-annually", 1L), ("storage-gb-monthly", 10L)]);
|
|
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
|
|
|
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
|
.Returns(AsyncEnumerable.Create(subscription));
|
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
|
.Returns(subscription);
|
|
|
|
// Mock GetIdsFromMetadata to return userId
|
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
// Act
|
|
await _sut.Execute(context);
|
|
|
|
// Assert - Verify Stripe update happened
|
|
await _stripeFacade.Received(1).UpdateSubscription(
|
|
"sub_personal",
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Quantity == 6));
|
|
|
|
// Assert - Verify database update with correct MaxStorageGb (5 included + 6 new quantity = 11)
|
|
await _userRepository.Received(1).GetByIdAsync(userId);
|
|
await _userRepository.Received(1).ReplaceAsync(user);
|
|
Assert.Equal((short)11, user.MaxStorageGb);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_LiveMode_OrganizationSubscription_UpdatesOrganizationDatabase()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
// Setup organization
|
|
var organizationId = Guid.NewGuid();
|
|
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
|
{
|
|
Id = organizationId,
|
|
Name = "Test Organization",
|
|
GatewaySubscriptionId = "sub_org",
|
|
MaxStorageGb = 13 // Old value
|
|
};
|
|
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
|
_organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask);
|
|
|
|
// Create organization subscription with org seat plan + 8 GB storage (will be reduced to 4 GB)
|
|
var subscription = CreateSubscriptionWithMultipleItems("sub_org",
|
|
[("2023-teams-org-seat-annually", 5L), ("storage-gb-monthly", 8L)]);
|
|
subscription.Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() };
|
|
|
|
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
|
.Returns(AsyncEnumerable.Create(subscription));
|
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
|
.Returns(subscription);
|
|
|
|
// Mock GetIdsFromMetadata to return organizationId
|
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
|
|
|
|
// Act
|
|
await _sut.Execute(context);
|
|
|
|
// Assert - Verify Stripe update happened
|
|
await _stripeFacade.Received(1).UpdateSubscription(
|
|
"sub_org",
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Quantity == 4));
|
|
|
|
// Assert - Verify database update with correct MaxStorageGb (5 included + 4 new quantity = 9)
|
|
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
|
|
await _organizationRepository.Received(1).ReplaceAsync(organization);
|
|
Assert.Equal((short)9, organization.MaxStorageGb);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_LiveMode_StorageItemDeleted_UpdatesDatabaseWithBaseStorage()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
// Setup user
|
|
var userId = Guid.NewGuid();
|
|
var user = new Bit.Core.Entities.User
|
|
{
|
|
Id = userId,
|
|
Email = "test@example.com",
|
|
GatewaySubscriptionId = "sub_delete",
|
|
MaxStorageGb = 8 // Old value
|
|
};
|
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
|
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
|
|
|
// Create personal subscription with premium seat + 3 GB storage (will be deleted since 3 < 4)
|
|
var subscription = CreateSubscriptionWithMultipleItems("sub_delete",
|
|
[("premium-annually", 1L), ("storage-gb-monthly", 3L)]);
|
|
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
|
|
|
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
|
.Returns(AsyncEnumerable.Create(subscription));
|
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
|
.Returns(subscription);
|
|
|
|
// Mock GetIdsFromMetadata to return userId
|
|
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
// Act
|
|
await _sut.Execute(context);
|
|
|
|
// Assert - Verify Stripe update happened (item deleted)
|
|
await _stripeFacade.Received(1).UpdateSubscription(
|
|
"sub_delete",
|
|
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Deleted == true));
|
|
|
|
// Assert - Verify database update with base storage only (5 GB)
|
|
await _userRepository.Received(1).GetByIdAsync(userId);
|
|
await _userRepository.Received(1).ReplaceAsync(user);
|
|
Assert.Equal((short)5, user.MaxStorageGb);
|
|
}
|
|
|
|
#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 userId = Guid.NewGuid();
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
[StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date"
|
|
};
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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>());
|
|
}
|
|
|
|
[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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.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 userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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.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 userId = Guid.NewGuid();
|
|
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);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
_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 userId = Guid.NewGuid();
|
|
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);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
_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 userId = Guid.NewGuid();
|
|
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);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
_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 Subscription Status Filtering Tests
|
|
|
|
[Fact]
|
|
public async Task Execute_ActiveStatusSubscription_ProcessesSubscription()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
var userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_TrialingStatusSubscription_ProcessesSubscription()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
var userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Trialing);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_PastDueStatusSubscription_ProcessesSubscription()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
var userId = Guid.NewGuid();
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.PastDue);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, 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>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_CanceledStatusSubscription_SkipsSubscription()
|
|
{
|
|
// 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, status: StripeConstants.SubscriptionStatus.Canceled);
|
|
|
|
_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_IncompleteStatusSubscription_SkipsSubscription()
|
|
{
|
|
// 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, status: StripeConstants.SubscriptionStatus.Incomplete);
|
|
|
|
_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_MixedSubscriptionStatuses_OnlyProcessesValidStatuses()
|
|
{
|
|
// Arrange
|
|
var context = CreateJobExecutionContext();
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
|
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
|
|
|
var userId = Guid.NewGuid();
|
|
var activeSubscription = CreateSubscription("sub_active", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active);
|
|
var trialingSubscription = CreateSubscription("sub_trialing", "storage-gb-monthly", quantity: 8, status: StripeConstants.SubscriptionStatus.Trialing);
|
|
var pastDueSubscription = CreateSubscription("sub_pastdue", "storage-gb-monthly", quantity: 6, status: StripeConstants.SubscriptionStatus.PastDue);
|
|
var canceledSubscription = CreateSubscription("sub_canceled", "storage-gb-monthly", quantity: 5, status: StripeConstants.SubscriptionStatus.Canceled);
|
|
var incompleteSubscription = CreateSubscription("sub_incomplete", "storage-gb-monthly", quantity: 4, status: StripeConstants.SubscriptionStatus.Incomplete);
|
|
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
|
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
|
|
|
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
|
.Returns(AsyncEnumerable.Create(activeSubscription, trialingSubscription, pastDueSubscription, canceledSubscription, incompleteSubscription));
|
|
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
|
.Returns(callInfo => callInfo.Arg<string>() switch
|
|
{
|
|
"sub_active" => activeSubscription,
|
|
"sub_trialing" => trialingSubscription,
|
|
"sub_pastdue" => pastDueSubscription,
|
|
_ => null
|
|
});
|
|
|
|
// Act
|
|
await _sut.Execute(context);
|
|
|
|
// Assert
|
|
await _stripeFacade.Received(1).UpdateSubscription("sub_active", Arg.Any<SubscriptionUpdateOptions>());
|
|
await _stripeFacade.Received(1).UpdateSubscription("sub_trialing", Arg.Any<SubscriptionUpdateOptions>());
|
|
await _stripeFacade.Received(1).UpdateSubscription("sub_pastdue", Arg.Any<SubscriptionUpdateOptions>());
|
|
await _stripeFacade.DidNotReceive().UpdateSubscription("sub_canceled", Arg.Any<SubscriptionUpdateOptions>());
|
|
await _stripeFacade.DidNotReceive().UpdateSubscription("sub_incomplete", Arg.Any<SubscriptionUpdateOptions>());
|
|
}
|
|
|
|
#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 Method Tests
|
|
|
|
#region DetermineSubscriptionPlanTier Tests
|
|
|
|
[Fact]
|
|
public void DetermineSubscriptionPlanTier_WithUserId_ReturnsPersonal()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
Guid? organizationId = null;
|
|
|
|
// Act
|
|
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
|
|
|
// Assert
|
|
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetermineSubscriptionPlanTier_WithOrganizationId_ReturnsOrganization()
|
|
{
|
|
// Arrange
|
|
Guid? userId = null;
|
|
var organizationId = Guid.NewGuid();
|
|
|
|
// Act
|
|
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
|
|
|
// Assert
|
|
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetermineSubscriptionPlanTier_WithBothIds_ReturnsPersonal()
|
|
{
|
|
// Arrange - Personal takes precedence
|
|
var userId = Guid.NewGuid();
|
|
var organizationId = Guid.NewGuid();
|
|
|
|
// Act
|
|
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
|
|
|
// Assert
|
|
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetermineSubscriptionPlanTier_WithNoIds_ReturnsUnknown()
|
|
{
|
|
// Arrange
|
|
Guid? userId = null;
|
|
Guid? organizationId = null;
|
|
|
|
// Act
|
|
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
|
|
|
// Assert
|
|
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, result);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region GetCurrentStorageQuantityFromSubscription Tests
|
|
|
|
[Theory]
|
|
[InlineData("storage-gb-monthly", 10L, 10L)]
|
|
[InlineData("storage-gb-annually", 25L, 25L)]
|
|
[InlineData("personal-storage-gb-annually", 5L, 5L)]
|
|
[InlineData("storage-gb-monthly", 0L, 0L)]
|
|
public void GetCurrentStorageQuantityFromSubscription_WithMatchingPriceId_ReturnsQuantity(
|
|
string priceId, long quantity, long expectedQuantity)
|
|
{
|
|
// Arrange
|
|
var subscription = CreateSubscription("sub_123", priceId, quantity);
|
|
|
|
// Act
|
|
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, priceId);
|
|
|
|
// Assert
|
|
Assert.Equal(expectedQuantity, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetCurrentStorageQuantityFromSubscription_WithNonMatchingPriceId_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", 10L);
|
|
|
|
// Act
|
|
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "different-price-id");
|
|
|
|
// Assert
|
|
Assert.Equal(0, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetCurrentStorageQuantityFromSubscription_WithNullItems_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var subscription = new Subscription { Id = "sub_123", Items = null };
|
|
|
|
// Act
|
|
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly");
|
|
|
|
// Assert
|
|
Assert.Equal(0, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetCurrentStorageQuantityFromSubscription_WithEmptyItems_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var subscription = new Subscription
|
|
{
|
|
Id = "sub_123",
|
|
Items = new StripeList<SubscriptionItem> { Data = [] }
|
|
};
|
|
|
|
// Act
|
|
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly");
|
|
|
|
// Assert
|
|
Assert.Equal(0, result);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CalculateNewMaxStorageGb Tests
|
|
|
|
[Theory]
|
|
[InlineData(10L, 6L, 11)] // 5 included + 6 new quantity
|
|
[InlineData(15L, 11L, 16)] // 5 included + 11 new quantity
|
|
[InlineData(4L, 0L, 5)] // Item deleted, returns base storage
|
|
[InlineData(2L, 0L, 5)] // Item deleted, returns base storage
|
|
[InlineData(8L, 4L, 9)] // 5 included + 4 new quantity
|
|
public void CalculateNewMaxStorageGb_WithQuantityUpdate_ReturnsCorrectMaxStorage(
|
|
long currentQuantity, long newQuantity, short expectedMaxStorageGb)
|
|
{
|
|
// Arrange
|
|
var updateOptions = new SubscriptionUpdateOptions
|
|
{
|
|
Items =
|
|
[
|
|
newQuantity == 0
|
|
? new SubscriptionItemOptions { Id = "si_123", Deleted = true } // Item marked as deleted
|
|
: new SubscriptionItemOptions { Id = "si_123", Quantity = newQuantity } // Item quantity updated
|
|
]
|
|
};
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
|
|
|
// Assert
|
|
Assert.Equal(expectedMaxStorageGb, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateNewMaxStorageGb_WithNullUpdateOptions_ReturnsCurrentQuantityPlusBaseIncluded()
|
|
{
|
|
// Arrange
|
|
const long currentQuantity = 10;
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, null);
|
|
|
|
// Assert
|
|
Assert.Equal((short)(5 + currentQuantity), result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateNewMaxStorageGb_WithNullItems_ReturnsCurrentQuantityPlusBaseIncluded()
|
|
{
|
|
// Arrange
|
|
const long currentQuantity = 10;
|
|
var updateOptions = new SubscriptionUpdateOptions { Items = null };
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
|
|
|
// Assert
|
|
Assert.Equal(5 + currentQuantity, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateNewMaxStorageGb_WithEmptyItems_ReturnsCurrentQuantity()
|
|
{
|
|
// Arrange
|
|
const long currentQuantity = 10;
|
|
var updateOptions = new SubscriptionUpdateOptions
|
|
{
|
|
Items = []
|
|
};
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
|
|
|
// Assert
|
|
Assert.Equal(5 + currentQuantity, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateNewMaxStorageGb_WithDeletedItem_ReturnsBaseStorage()
|
|
{
|
|
// Arrange
|
|
const long currentQuantity = 100;
|
|
var updateOptions = new SubscriptionUpdateOptions
|
|
{
|
|
Items = [new SubscriptionItemOptions { Id = "si_123", Deleted = true }]
|
|
};
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
|
|
|
// Assert
|
|
Assert.Equal((short)5, result); // Base storage
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateNewMaxStorageGb_WithItemWithoutQuantity_ReturnsCurrentQuantity()
|
|
{
|
|
// Arrange
|
|
const long currentQuantity = 10;
|
|
var updateOptions = new SubscriptionUpdateOptions
|
|
{
|
|
Items = [new SubscriptionItemOptions { Id = "si_123", Quantity = null }]
|
|
};
|
|
|
|
// Act
|
|
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
|
|
|
// Assert
|
|
Assert.Equal(5 + currentQuantity, result);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region UpdateDatabaseMaxStorageAsync Tests
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UpdatesUser()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
var user = new Bit.Core.Entities.User
|
|
{
|
|
Id = userId,
|
|
Email = "test@example.com",
|
|
GatewaySubscriptionId = "sub_123"
|
|
};
|
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
|
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
|
userId,
|
|
10,
|
|
"sub_123");
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
Assert.Equal((short)10, user.MaxStorageGb);
|
|
await _userRepository.Received(1).GetByIdAsync(userId);
|
|
await _userRepository.Received(1).ReplaceAsync(user);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UserNotFound_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
_userRepository.GetByIdAsync(userId).Returns((Bit.Core.Entities.User?)null);
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
|
userId,
|
|
10,
|
|
"sub_123");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_ReplaceThrowsException_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
var user = new Bit.Core.Entities.User
|
|
{
|
|
Id = userId,
|
|
Email = "test@example.com",
|
|
GatewaySubscriptionId = "sub_123"
|
|
};
|
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
|
_userRepository.ReplaceAsync(user).Throws(new Exception("Database error"));
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
|
userId,
|
|
10,
|
|
"sub_123");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_UpdatesOrganization()
|
|
{
|
|
// Arrange
|
|
var organizationId = Guid.NewGuid();
|
|
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
|
{
|
|
Id = organizationId,
|
|
Name = "Test Org",
|
|
GatewaySubscriptionId = "sub_456"
|
|
};
|
|
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
|
_organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
|
organizationId,
|
|
20,
|
|
"sub_456");
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
Assert.Equal((short)20, organization.MaxStorageGb);
|
|
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
|
|
await _organizationRepository.Received(1).ReplaceAsync(organization);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_OrganizationNotFound_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var organizationId = Guid.NewGuid();
|
|
_organizationRepository.GetByIdAsync(organizationId)
|
|
.Returns((Bit.Core.AdminConsole.Entities.Organization?)null);
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
|
organizationId,
|
|
20,
|
|
"sub_456");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_ReplaceThrowsException_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var organizationId = Guid.NewGuid();
|
|
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
|
{
|
|
Id = organizationId,
|
|
Name = "Test Org",
|
|
GatewaySubscriptionId = "sub_456"
|
|
};
|
|
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
|
_organizationRepository.ReplaceAsync(organization).Throws(new Exception("Database error"));
|
|
|
|
// Act
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
|
organizationId,
|
|
20,
|
|
"sub_456");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDatabaseMaxStorageAsync_UnknownTier_ReturnsFalse()
|
|
{
|
|
// Arrange & Act
|
|
var entityId = Guid.NewGuid();
|
|
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
|
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown,
|
|
entityId,
|
|
15,
|
|
"sub_789");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
|
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#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,
|
|
string status = StripeConstants.SubscriptionStatus.Active)
|
|
{
|
|
var price = new Price { Id = priceId };
|
|
var item = new SubscriptionItem
|
|
{
|
|
Id = $"si_{id}",
|
|
Price = price,
|
|
Quantity = quantity ?? 0
|
|
};
|
|
|
|
return new Subscription
|
|
{
|
|
Id = id,
|
|
Status = status,
|
|
Metadata = metadata,
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data = [item]
|
|
}
|
|
};
|
|
}
|
|
|
|
private static Subscription CreateSubscriptionWithMultipleItems(string id, (string priceId, long quantity)[] items)
|
|
{
|
|
var subscriptionItems = items.Select(i => new SubscriptionItem
|
|
{
|
|
Id = $"si_{id}_{i.priceId}",
|
|
Price = new Price { Id = i.priceId },
|
|
Quantity = i.quantity
|
|
}).ToList();
|
|
|
|
return new Subscription
|
|
{
|
|
Id = id,
|
|
Status = StripeConstants.SubscriptionStatus.Active,
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data = subscriptionItems
|
|
}
|
|
};
|
|
}
|
|
|
|
#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;
|
|
}
|
|
}
|