using Elsa.Common.Multitenancy;
using Elsa.Common.Multitenancy.EventHandlers;
using Elsa.Common.RecurringTasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Elsa.Common.UnitTests.Multitenancy;
public class TenantTaskManagerTests : IAsyncDisposable
{
private readonly TenantTaskManager _manager = CreateManager();
private readonly Tenant _tenant = new() { Id = "test-tenant" };
private readonly TrackingRecurringTask _recurringTask = new();
private readonly IServiceProvider _serviceProvider;
public TenantTaskManagerTests() =>
_serviceProvider = BuildTenantServiceProvider(recurringTasks: [_recurringTask]);
public ValueTask DisposeAsync() => _manager.DisposeAsync();
///
/// Regression test: before the fix, TryRemove was called before WaitAsync,
/// so a cancelled WaitAsync would orphan the state (removed from the dictionary but
/// never stopped). would then skip cleanup,
/// leaving background tasks and recurring-task timers running until process exit.
///
[Fact]
public async Task DeactivateAsync_WhenCancelledBeforeGateAcquired_StateRemainsForDispose()
{
await ActivateAsync();
using var cancelledCts = new CancellationTokenSource();
await cancelledCts.CancelAsync();
// WaitAsync throws immediately for an already-cancelled token, before TryRemove is reached.
await Assert.ThrowsAnyAsync(
() => _manager.TenantDeactivatedAsync(DeactivationArgs(cancelledCts.Token)));
// DisposeAsync must still find the state and stop the recurring task.
await _manager.DisposeAsync();
Assert.True(_recurringTask.WasStopCalled);
}
[Fact]
public async Task DeactivateAsync_AfterActivation_StopsRecurringTasks()
{
await ActivateAsync();
await _manager.TenantDeactivatedAsync(DeactivationArgs());
Assert.True(_recurringTask.WasStopCalled);
}
[Fact]
public async Task DisposeAsync_WithActiveTenant_StopsRecurringTasks()
{
await ActivateAsync();
await _manager.DisposeAsync();
Assert.True(_recurringTask.WasStopCalled);
}
// === Instance helpers ===
private Task ActivateAsync() =>
_manager.TenantActivatedAsync(new TenantActivatedEventArgs(_tenant, CreateTenantScope(_tenant, _serviceProvider), CancellationToken.None));
private TenantDeactivatedEventArgs DeactivationArgs(CancellationToken cancellationToken = default) =>
new(_tenant, CreateTenantScope(_tenant, _serviceProvider), cancellationToken);
// === Static helpers ===
private static TenantTaskManager CreateManager()
{
var scheduleManager = new RecurringTaskScheduleManager(Options.Create(new RecurringTaskOptions()), Substitute.For());
return new TenantTaskManager(scheduleManager, NullLogger.Instance);
}
private static IServiceProvider BuildTenantServiceProvider(IEnumerable? recurringTasks = null)
{
var services = new ServiceCollection();
services.AddSingleton(new InstantTaskExecutor());
services.AddSingleton(Substitute.For());
foreach (var task in recurringTasks ?? [])
services.AddSingleton(task);
return services.BuildServiceProvider();
}
private static TenantScope CreateTenantScope(Tenant tenant, IServiceProvider serviceProvider)
{
var serviceScope = Substitute.For();
serviceScope.ServiceProvider.Returns(serviceProvider);
var tenantAccessor = Substitute.For();
tenantAccessor.PushContext(Arg.Any()).Returns(Substitute.For());
return new TenantScope(serviceScope, tenantAccessor, tenant);
}
// === Test doubles ===
private class TrackingRecurringTask : IRecurringTask
{
public bool WasStopCalled { get; private set; }
public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
{
WasStopCalled = true;
return Task.CompletedTask;
}
}
private class InstantTaskExecutor : ITaskExecutor
{
public Task ExecuteTaskAsync(ITask task, CancellationToken cancellationToken) => Task.CompletedTask;
}
}