4 changed files with 500 additions and 2 deletions
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below |
||||
#nullable disable |
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Quartz; |
||||
|
||||
namespace Bit.Billing.Jobs; |
||||
|
||||
public class ProviderOrganizationDisableJob( |
||||
IProviderOrganizationRepository providerOrganizationRepository, |
||||
IOrganizationDisableCommand organizationDisableCommand, |
||||
ILogger<ProviderOrganizationDisableJob> logger) |
||||
: IJob |
||||
{ |
||||
private const int MaxConcurrency = 5; |
||||
private const int MaxTimeoutMinutes = 10; |
||||
|
||||
public async Task Execute(IJobExecutionContext context) |
||||
{ |
||||
var providerId = new Guid(context.MergedJobDataMap.GetString("providerId") ?? string.Empty); |
||||
var expirationDateString = context.MergedJobDataMap.GetString("expirationDate"); |
||||
DateTime? expirationDate = string.IsNullOrEmpty(expirationDateString) |
||||
? null |
||||
: DateTime.Parse(expirationDateString); |
||||
|
||||
logger.LogInformation("Starting to disable organizations for provider {ProviderId}", providerId); |
||||
|
||||
var startTime = DateTime.UtcNow; |
||||
var totalProcessed = 0; |
||||
var totalErrors = 0; |
||||
|
||||
try |
||||
{ |
||||
var providerOrganizations = await providerOrganizationRepository |
||||
.GetManyDetailsByProviderAsync(providerId); |
||||
|
||||
if (providerOrganizations == null || !providerOrganizations.Any()) |
||||
{ |
||||
logger.LogInformation("No organizations found for provider {ProviderId}", providerId); |
||||
return; |
||||
} |
||||
|
||||
logger.LogInformation("Disabling {OrganizationCount} organizations for provider {ProviderId}", |
||||
providerOrganizations.Count, providerId); |
||||
|
||||
var semaphore = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); |
||||
var tasks = providerOrganizations.Select(async po => |
||||
{ |
||||
if (DateTime.UtcNow.Subtract(startTime).TotalMinutes > MaxTimeoutMinutes) |
||||
{ |
||||
logger.LogWarning("Timeout reached while disabling organizations for provider {ProviderId}", providerId); |
||||
return false; |
||||
} |
||||
|
||||
await semaphore.WaitAsync(); |
||||
try |
||||
{ |
||||
await organizationDisableCommand.DisableAsync(po.OrganizationId, expirationDate); |
||||
Interlocked.Increment(ref totalProcessed); |
||||
return true; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
logger.LogError(ex, "Failed to disable organization {OrganizationId} for provider {ProviderId}", |
||||
po.OrganizationId, providerId); |
||||
Interlocked.Increment(ref totalErrors); |
||||
return false; |
||||
} |
||||
finally |
||||
{ |
||||
semaphore.Release(); |
||||
} |
||||
}); |
||||
|
||||
await Task.WhenAll(tasks); |
||||
|
||||
logger.LogInformation("Completed disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}", |
||||
providerId, totalProcessed, totalErrors); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
logger.LogError(ex, "Error disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}", |
||||
providerId, totalProcessed, totalErrors); |
||||
throw; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
using Bit.Billing.Jobs; |
||||
using Bit.Core.AdminConsole.Models.Data.Provider; |
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Microsoft.Extensions.Logging; |
||||
using NSubstitute; |
||||
using NSubstitute.ExceptionExtensions; |
||||
using Quartz; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Billing.Test.Jobs; |
||||
|
||||
public class ProviderOrganizationDisableJobTests |
||||
{ |
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository; |
||||
private readonly IOrganizationDisableCommand _organizationDisableCommand; |
||||
private readonly ILogger<ProviderOrganizationDisableJob> _logger; |
||||
private readonly ProviderOrganizationDisableJob _sut; |
||||
|
||||
public ProviderOrganizationDisableJobTests() |
||||
{ |
||||
_providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>(); |
||||
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>(); |
||||
_logger = Substitute.For<ILogger<ProviderOrganizationDisableJob>>(); |
||||
_sut = new ProviderOrganizationDisableJob( |
||||
_providerOrganizationRepository, |
||||
_organizationDisableCommand, |
||||
_logger); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_NoOrganizations_LogsAndReturns() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns((ICollection<ProviderOrganizationOrganizationDetails>)null); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_WithOrganizations_DisablesAllOrganizations() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var expirationDate = DateTime.UtcNow.AddDays(30); |
||||
var org1Id = Guid.NewGuid(); |
||||
var org2Id = Guid.NewGuid(); |
||||
var org3Id = Guid.NewGuid(); |
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails> |
||||
{ |
||||
new() { OrganizationId = org1Id }, |
||||
new() { OrganizationId = org2Id }, |
||||
new() { OrganizationId = org3Id } |
||||
}; |
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(organizations); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>()); |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>()); |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_WithExpirationDate_PassesDateToDisableCommand() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var expirationDate = new DateTime(2025, 12, 31, 23, 59, 59); |
||||
var orgId = Guid.NewGuid(); |
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails> |
||||
{ |
||||
new() { OrganizationId = orgId } |
||||
}; |
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(organizations); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
await _organizationDisableCommand.Received(1).DisableAsync(orgId, expirationDate); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_WithNullExpirationDate_PassesNullToDisableCommand() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var orgId = Guid.NewGuid(); |
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails> |
||||
{ |
||||
new() { OrganizationId = orgId } |
||||
}; |
||||
|
||||
var context = CreateJobExecutionContext(providerId, null); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(organizations); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
await _organizationDisableCommand.Received(1).DisableAsync(orgId, null); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_OneOrganizationFails_ContinuesProcessingOthers() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var expirationDate = DateTime.UtcNow.AddDays(30); |
||||
var org1Id = Guid.NewGuid(); |
||||
var org2Id = Guid.NewGuid(); |
||||
var org3Id = Guid.NewGuid(); |
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails> |
||||
{ |
||||
new() { OrganizationId = org1Id }, |
||||
new() { OrganizationId = org2Id }, |
||||
new() { OrganizationId = org3Id } |
||||
}; |
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(organizations); |
||||
|
||||
// Make org2 fail |
||||
_organizationDisableCommand.DisableAsync(org2Id, Arg.Any<DateTime?>()) |
||||
.Throws(new Exception("Database error")); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert - all three should be attempted |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>()); |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>()); |
||||
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_ManyOrganizations_ProcessesWithLimitedConcurrency() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var expirationDate = DateTime.UtcNow.AddDays(30); |
||||
|
||||
// Create 20 organizations |
||||
var organizations = Enumerable.Range(1, 20) |
||||
.Select(_ => new ProviderOrganizationOrganizationDetails { OrganizationId = Guid.NewGuid() }) |
||||
.ToList(); |
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(organizations); |
||||
|
||||
var concurrentCalls = 0; |
||||
var maxConcurrentCalls = 0; |
||||
var lockObj = new object(); |
||||
|
||||
_organizationDisableCommand.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>()) |
||||
.Returns(callInfo => |
||||
{ |
||||
lock (lockObj) |
||||
{ |
||||
concurrentCalls++; |
||||
if (concurrentCalls > maxConcurrentCalls) |
||||
{ |
||||
maxConcurrentCalls = concurrentCalls; |
||||
} |
||||
} |
||||
|
||||
return Task.Delay(50).ContinueWith(_ => |
||||
{ |
||||
lock (lockObj) |
||||
{ |
||||
concurrentCalls--; |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
Assert.True(maxConcurrentCalls <= 5, $"Expected max concurrency of 5, but got {maxConcurrentCalls}"); |
||||
await _organizationDisableCommand.Received(20).DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task Execute_EmptyOrganizationsList_DoesNotCallDisableCommand() |
||||
{ |
||||
// Arrange |
||||
var providerId = Guid.NewGuid(); |
||||
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow); |
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) |
||||
.Returns(new List<ProviderOrganizationOrganizationDetails>()); |
||||
|
||||
// Act |
||||
await _sut.Execute(context); |
||||
|
||||
// Assert |
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); |
||||
} |
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(Guid providerId, DateTime? expirationDate) |
||||
{ |
||||
var context = Substitute.For<IJobExecutionContext>(); |
||||
var jobDataMap = new JobDataMap |
||||
{ |
||||
{ "providerId", providerId.ToString() }, |
||||
{ "expirationDate", expirationDate?.ToString("O") } |
||||
}; |
||||
context.MergedJobDataMap.Returns(jobDataMap); |
||||
return context; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue