mirror of
https://github.com/bitwarden/server
synced 2026-01-14 06:23:46 +00:00
[PM-30460] update storage job to also update database max storage (#6803)
* [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
This commit is contained in:
@@ -4,6 +4,7 @@ using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Jobs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
@@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs;
|
||||
public class ReconcileAdditionalStorageJob(
|
||||
IStripeFacade stripeFacade,
|
||||
ILogger<ReconcileAdditionalStorageJob> logger,
|
||||
IFeatureService featureService) : BaseJob(logger)
|
||||
IFeatureService featureService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger)
|
||||
{
|
||||
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
|
||||
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
|
||||
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
|
||||
private const int _storageGbToRemove = 4;
|
||||
private const short _includedStorageGb = 5;
|
||||
|
||||
public enum SubscriptionPlanTier
|
||||
{
|
||||
Personal,
|
||||
Organization,
|
||||
Unknown
|
||||
}
|
||||
|
||||
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||
{
|
||||
@@ -34,6 +46,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
var subscriptionsFound = 0;
|
||||
var subscriptionsUpdated = 0;
|
||||
var subscriptionsWithErrors = 0;
|
||||
var databaseUpdatesFailed = 0;
|
||||
var failures = new List<string>();
|
||||
|
||||
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||
@@ -51,11 +64,13 @@ public class ReconcileAdditionalStorageJob(
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " +
|
||||
"Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
@@ -99,20 +114,68 @@ public class ReconcileAdditionalStorageJob(
|
||||
|
||||
subscriptionsUpdated++;
|
||||
|
||||
if (!liveMode)
|
||||
// Now, prepare the database update so we can log details out if not in live mode
|
||||
var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary<string, string>());
|
||||
var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
if (subscriptionPlanTier == SubscriptionPlanTier.Unknown)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions));
|
||||
logger.LogError(
|
||||
"Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ",
|
||||
subscription.Id);
|
||||
subscriptionsWithErrors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var entityId =
|
||||
subscriptionPlanTier switch
|
||||
{
|
||||
SubscriptionPlanTier.Personal => userId!.Value,
|
||||
SubscriptionPlanTier.Organization => organizationId!.Value,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null)
|
||||
};
|
||||
|
||||
// Calculate new MaxStorageGb
|
||||
var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId);
|
||||
var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions);
|
||||
|
||||
if (!liveMode)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" +
|
||||
"{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions),
|
||||
Environment.NewLine,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Live mode enabled - continue with updates to stripe and database
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
|
||||
logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}",
|
||||
subscription.Id,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
|
||||
var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync(
|
||||
subscriptionPlanTier,
|
||||
entityId,
|
||||
newMaxStorageGb,
|
||||
subscription.Id);
|
||||
|
||||
if (!dbUpdateSuccess)
|
||||
{
|
||||
databaseUpdatesFailed++;
|
||||
failures.Add($"Subscription {subscription.Id}: Database update failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -125,12 +188,14 @@ public class ReconcileAdditionalStorageJob(
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " +
|
||||
"Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
@@ -182,6 +247,117 @@ public class ReconcileAdditionalStorageJob(
|
||||
return hasUpdates ? updateOptions : null;
|
||||
}
|
||||
|
||||
public SubscriptionPlanTier DetermineSubscriptionPlanTier(
|
||||
Guid? userId,
|
||||
Guid? organizationId)
|
||||
{
|
||||
return userId.HasValue
|
||||
? SubscriptionPlanTier.Personal
|
||||
: organizationId.HasValue
|
||||
? SubscriptionPlanTier.Organization
|
||||
: SubscriptionPlanTier.Unknown;
|
||||
}
|
||||
|
||||
public long GetCurrentStorageQuantityFromSubscription(
|
||||
Subscription subscription,
|
||||
string storagePriceId)
|
||||
{
|
||||
return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0;
|
||||
}
|
||||
|
||||
public short CalculateNewMaxStorageGb(
|
||||
long currentQuantity,
|
||||
SubscriptionUpdateOptions? updateOptions)
|
||||
{
|
||||
if (updateOptions?.Items == null)
|
||||
{
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
// If the update marks item as deleted, new quantity is whatever the base storage gb
|
||||
if (updateOptions.Items.Any(i => i.Deleted == true))
|
||||
{
|
||||
return _includedStorageGb;
|
||||
}
|
||||
|
||||
// If the update has a new quantity, use it to calculate the new max
|
||||
var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue);
|
||||
if (updatedItem?.Quantity != null)
|
||||
{
|
||||
return (short)(_includedStorageGb + updatedItem.Quantity.Value);
|
||||
}
|
||||
|
||||
// Otherwise, no change
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateDatabaseMaxStorageAsync(
|
||||
SubscriptionPlanTier subscriptionPlanTier,
|
||||
Guid entityId,
|
||||
short newMaxStorageGb,
|
||||
string subscriptionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (subscriptionPlanTier)
|
||||
{
|
||||
case SubscriptionPlanTier.Personal:
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(entityId);
|
||||
if (user == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"User not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.MaxStorageGb = newMaxStorageGb;
|
||||
await userRepository.ReplaceAsync(user);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
user.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Organization:
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(entityId);
|
||||
if (organization == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Organization not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
organization.MaxStorageGb = newMaxStorageGb;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
organization.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Unknown:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})",
|
||||
subscriptionId,
|
||||
subscriptionPlanTier);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ITrigger GetTrigger()
|
||||
{
|
||||
return TriggerBuilder.Create()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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;
|
||||
@@ -17,6 +18,9 @@ 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()
|
||||
@@ -24,7 +28,20 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_stripeFacade = Substitute.For<IStripeFacade>();
|
||||
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService);
|
||||
_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
|
||||
@@ -88,6 +105,36 @@ public class ReconcileAdditionalStorageJobTests
|
||||
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()
|
||||
{
|
||||
@@ -96,7 +143,11 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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>())
|
||||
@@ -111,6 +162,150 @@ public class ReconcileAdditionalStorageJobTests
|
||||
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
|
||||
@@ -174,11 +369,14 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -200,7 +398,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -226,7 +427,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -253,7 +457,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -279,7 +486,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -309,7 +519,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -333,7 +546,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -429,9 +645,12 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -461,6 +680,7 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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")
|
||||
@@ -469,6 +689,8 @@ public class ReconcileAdditionalStorageJobTests
|
||||
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));
|
||||
@@ -501,9 +723,12 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -563,7 +788,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -585,7 +813,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -607,7 +838,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -669,11 +903,14 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_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));
|
||||
@@ -731,6 +968,410 @@ public class ReconcileAdditionalStorageJobTests
|
||||
|
||||
#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)
|
||||
@@ -762,7 +1403,27 @@ public class ReconcileAdditionalStorageJobTests
|
||||
Metadata = metadata,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem> { item }
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user