elsa-core/test/unit/Elsa.Common.UnitTests/Multitenancy/DefaultTenantServiceTests.cs
Sipke Schoorstra ec9acd4f3f
Fix tenant service mutation race (#7898)
Serialize tenant lifecycle mutations and keep synchronization available through shutdown.

Closes #7771.
2026-07-30 02:47:44 +02:00

292 lines
10 KiB
C#

using Elsa.Common.Multitenancy;
using Elsa.Common.Multitenancy.EventHandlers;
using Elsa.Common.RecurringTasks;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Common.UnitTests.Multitenancy;
/// <summary>
/// Tests for <see cref="DefaultTenantService"/>, including the fallback to activate
/// <see cref="Tenant.Default"/> when the tenant provider returns an empty list.
/// </summary>
public class DefaultTenantServiceTests
{
[Fact]
public async Task ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant()
{
// Arrange - provider returns no tenants
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty<Tenant>());
try
{
// Act
await tenantService.ActivateTenantsAsync();
// Assert
var tenants = (await tenantService.ListAsync()).ToList();
Assert.Single(tenants);
Assert.Same(Tenant.Default, tenants[0]);
Assert.Equal(Tenant.DefaultTenantId, tenants[0].Id);
}
finally
{
if (tenantService is IAsyncDisposable disposable)
await disposable.DisposeAsync();
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant()
{
// Arrange - ListAsync triggers initialization when provider returns empty
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty<Tenant>());
try
{
// Act
var tenants = (await tenantService.ListAsync()).ToList();
// Assert
Assert.Single(tenants);
Assert.Same(Tenant.Default, tenants[0]);
}
finally
{
if (tenantService is IAsyncDisposable disposable)
await disposable.DisposeAsync();
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants()
{
// Arrange - provider returns specific tenants
var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
var tenant2 = new Tenant { Id = "tenant-2", Name = "Tenant 2" };
var (tenantService, serviceProvider) = await CreateTenantServiceAsync([tenant1, tenant2]);
try
{
// Act
await tenantService.ActivateTenantsAsync();
// Assert - should not use Tenant.Default fallback
var tenants = (await tenantService.ListAsync()).ToList();
Assert.Equal(2, tenants.Count);
Assert.Contains(tenants, t => t.Id == "tenant-1");
Assert.Contains(tenants, t => t.Id == "tenant-2");
}
finally
{
if (tenantService is IAsyncDisposable disposable)
await disposable.DisposeAsync();
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant()
{
// Arrange - start with tenants, then provider returns empty (simulating config change)
var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
var providerReturns = new List<Tenant> { tenant1 };
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(providerReturns, () => providerReturns);
try
{
await tenantService.ActivateTenantsAsync();
Assert.Single(await tenantService.ListAsync());
// Simulate provider now returning empty (e.g., config removed all tenants)
providerReturns.Clear();
// Act
await tenantService.RefreshAsync();
// Assert - should fall back to Tenant.Default instead of having zero tenants
var tenants = (await tenantService.ListAsync()).ToList();
Assert.Single(tenants);
Assert.Same(Tenant.Default, tenants[0]);
}
finally
{
if (tenantService is IAsyncDisposable disposable)
await disposable.DisposeAsync();
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task DeactivateTenantsAsync_WhenRefreshIsInProgress_WaitsForRefreshBeforeDeactivating()
{
var tenant = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
var timeout = TimeSpan.FromSeconds(5);
var refreshStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var completeRefresh = new TaskCompletionSource<IEnumerable<Tenant>>(TaskCreationOptions.RunContinuationsAsynchronously);
var listRequests = 0;
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(async _ =>
{
if (Interlocked.Increment(ref listRequests) == 1)
{
return new[] { tenant };
}
refreshStarted.TrySetResult();
return await completeRefresh.Task;
});
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(tenantsProvider);
Task? refreshTask = null;
Task? deactivateTask = null;
try
{
await tenantService.ListAsync();
refreshTask = tenantService.RefreshAsync();
await refreshStarted.Task.WaitAsync(timeout);
deactivateTask = tenantService.DeactivateTenantsAsync();
Assert.False(deactivateTask.IsCompleted);
completeRefresh.SetResult([tenant]);
await refreshTask.WaitAsync(timeout);
await deactivateTask.WaitAsync(timeout);
Assert.Empty(await tenantService.ListAsync());
}
finally
{
completeRefresh.TrySetResult([tenant]);
if (refreshTask != null)
{
await refreshTask.WaitAsync(timeout);
}
if (deactivateTask != null)
{
await deactivateTask.WaitAsync(timeout);
}
if (tenantService is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task DeactivateTenantsAsync_WhenInitializationIsInProgress_WaitsForInitializationBeforeDeactivating()
{
var tenant = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
var timeout = TimeSpan.FromSeconds(5);
var initializationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var completeInitialization = new TaskCompletionSource<IEnumerable<Tenant>>(TaskCreationOptions.RunContinuationsAsynchronously);
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(async _ =>
{
initializationStarted.TrySetResult();
return await completeInitialization.Task;
});
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(tenantsProvider);
Task<IEnumerable<Tenant>>? initializationTask = null;
Task? deactivateTask = null;
try
{
initializationTask = tenantService.ListAsync();
await initializationStarted.Task.WaitAsync(timeout);
deactivateTask = tenantService.DeactivateTenantsAsync();
Assert.False(deactivateTask.IsCompleted);
completeInitialization.SetResult([tenant]);
await initializationTask.WaitAsync(timeout);
await deactivateTask.WaitAsync(timeout);
Assert.Empty(await tenantService.ListAsync());
}
finally
{
completeInitialization.TrySetResult([tenant]);
if (initializationTask != null)
{
await initializationTask.WaitAsync(timeout);
}
if (deactivateTask != null)
{
await deactivateTask.WaitAsync(timeout);
}
if (tenantService is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
await serviceProvider.DisposeAsync();
}
}
[Fact]
public async Task DeactivateTenantsAsync_AfterDisposeAsync_DoesNotUseDisposedSynchronizationPrimitives()
{
var tenant = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
var (tenantService, serviceProvider) = await CreateTenantServiceAsync([tenant]);
try
{
await tenantService.ListAsync();
await ((IAsyncDisposable)tenantService).DisposeAsync();
await tenantService.DeactivateTenantsAsync();
Assert.Empty(await tenantService.ListAsync());
}
finally
{
await serviceProvider.DisposeAsync();
}
}
private static Task<(ITenantService TenantService, ServiceProvider ServiceProvider)> CreateTenantServiceAsync(IEnumerable<Tenant> tenants, Func<List<Tenant>>? tenantsFactory = null)
{
var tenantList = tenants.ToList();
var getTenants = tenantsFactory ?? (() => tenantList);
var tenantsProvider = Substitute.For<ITenantsProvider>();
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(_ => getTenants());
return CreateTenantServiceAsync(tenantsProvider);
}
private static Task<(ITenantService TenantService, ServiceProvider ServiceProvider)> CreateTenantServiceAsync(ITenantsProvider tenantsProvider)
{
var services = new ServiceCollection();
services.AddSingleton(_ => tenantsProvider);
services.AddSingleton<ITenantScopeFactory, DefaultTenantScopeFactory>();
services.AddSingleton<ITenantAccessor, DefaultTenantAccessor>();
services.AddSingleton<ITenantActivatedEvent>(Substitute.For<ITenantActivatedEvent>());
services.AddSingleton<ITenantDeactivatedEvent>(Substitute.For<ITenantDeactivatedEvent>());
services.AddSingleton<ITenantDeletedEvent>(Substitute.For<ITenantDeletedEvent>());
services.AddSingleton<TenantEventsManager>();
services.AddSingleton<RecurringTaskScheduleManager>();
services.AddSingleton<ITenantService, DefaultTenantService>();
services.AddLogging();
var serviceProvider = services.BuildServiceProvider();
return Task.FromResult((serviceProvider.GetRequiredService<ITenantService>(), serviceProvider));
}
}