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; } }