elsa-core/test/unit/Elsa.Features.UnitTests/ModuleTests.cs

241 lines
7.6 KiB
C#
Raw Permalink Normal View History

fix(features): support features introduced during Module.Apply() (#7966) Module.Apply() enumerated _features.Values directly while calling feature.Apply(). A feature whose Apply() introduces another feature — Module.Configure<T>() directly, or via a helper such as AddActivity<T>() which configures WorkflowManagementFeature — mutated that collection mid-enumeration and threw "Collection was modified; enumeration operation may not execute", naming nothing about features. Whether it fired depended on whether the other feature happened to be installed already, so a module built or did not based on unrelated host config. The module already treats introduction-during-apply as supported: the ConfigureFeature loop iterates a snapshot for exactly this reason, and Configure<T>() has an _isApplying branch that creates, resolves and configures a feature introduced mid-Apply. Only the final apply loop missed the same treatment, so make it tolerant rather than diagnose a constraint the code does not hold. The apply loop now runs in rounds until no new features appear, each round topologically sorted so a late feature's dependencies apply before it. Hosted services are registered in a single pass after that loop, then moved back to the index the block previously occupied: registering late is needed so features contributed during Apply() are included and ordered by priority, while keeping the position matters because features register hosted services directly from Apply() — WorkflowRuntimeFeature adds DrainOrchestratorHostedService that way — and module-managed services must keep starting first, or a priority such as ActivateTenants at -1 would silently start ordering after them. Adds Elsa.Features.UnitTests, covering the introduced feature applying, a three-deep introduction chain, dependency ordering, hosted service registration and priority ordering for late arrivals, the installed- feature registry, and no double-apply, plus guards for pre-existing ordering behaviour. Closes #7944 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:53:07 +00:00
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Contracts;
using Elsa.Features.Implementations;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Elsa.Features.UnitTests;
/// <summary>
/// Tests <see cref="Module.Apply"/>, with an emphasis on features that introduce additional features from their own <see cref="IFeature.Apply"/> method.
/// </summary>
public class ModuleTests
{
private const string AppliedFeaturesKey = "AppliedFeatures";
private readonly ServiceCollection _services = new();
private readonly Module _module;
private readonly List<Type> _appliedFeatures = new();
public ModuleTests()
{
_module = new(_services);
_module.Properties[AppliedFeaturesKey] = _appliedFeatures;
}
[Fact]
public void Apply_AppliesFeatureIntroducedFromApply()
{
_module.Configure<IntroducingFeature>();
_module.Apply();
Assert.Contains(typeof(IntroducedFeature), _appliedFeatures);
Assert.Contains(_services, x => x.ServiceType == typeof(IntroducedMarker));
}
[Fact]
public void Apply_AppliesEntireChainOfFeaturesIntroducedFromApply()
{
_module.Configure<ChainIntroducingFeature>();
_module.Apply();
Assert.Contains(typeof(ChainMiddleFeature), _appliedFeatures);
Assert.Contains(typeof(ChainLeafFeature), _appliedFeatures);
}
[Fact]
public void Apply_AppliesDependenciesOfFeatureIntroducedFromApplyBeforeThatFeature()
{
_module.Configure<IntroducingDependentFeature>();
_module.Apply();
Assert.Contains(typeof(IntroducedDependencyFeature), _appliedFeatures);
Assert.True(_appliedFeatures.IndexOf(typeof(IntroducedDependencyFeature)) < _appliedFeatures.IndexOf(typeof(IntroducedDependentFeature)));
}
[Fact]
public void Apply_RegistersHostedServicesOfFeatureIntroducedFromApply()
{
_module.Configure<IntroducingFeature>();
_module.Apply();
Assert.Contains(_services, x => x.ServiceType == typeof(IHostedService) && x.ImplementationType == typeof(IntroducedHostedService));
}
[Fact]
public void Apply_ListsFeatureIntroducedFromApplyInTheInstalledFeatureRegistry()
{
_module.Configure<IntroducingFeature>();
_module.Apply();
Assert.NotNull(GetInstalledFeatureRegistry().Find("Elsa.Introduced"));
}
[Fact]
public void Apply_AppliesEachFeatureOnlyOnce()
{
_module.Configure<IntroducingFeature>();
_module.Apply();
Assert.Equal(_appliedFeatures.Distinct().Count(), _appliedFeatures.Count);
}
[Fact]
public void Apply_AppliesFeaturesInDependencyOrder()
{
_module.Configure<DependentFeature>();
_module.Apply();
Assert.Equal([typeof(DependencyFeature), typeof(DependentFeature)], _appliedFeatures);
}
[Fact]
public void Apply_RegistersHostedServicesInPriorityOrder()
{
_module.ConfigureHostedService<SecondHostedService>(2);
_module.ConfigureHostedService<FirstHostedService>(1);
_module.Configure<IntroducingFeature>();
_module.Apply();
Assert.Equal([typeof(FirstHostedService), typeof(SecondHostedService), typeof(IntroducedHostedService)], GetHostedServiceTypes());
}
[Fact]
public void Apply_OrdersHostedServiceOfFeatureIntroducedFromApplyByPriority()
{
_module.ConfigureHostedService<SecondHostedService>(10);
_module.Configure<IntroducingFeature>();
_module.Apply();
// The introduced feature configures its hosted service at priority 3, so it has to come first even though it shows up last.
Assert.Equal([typeof(IntroducedHostedService), typeof(SecondHostedService)], GetHostedServiceTypes());
}
[Fact]
public void Apply_RegistersConfiguredHostedServicesBeforeThoseRegisteredFromApply()
{
_module.ConfigureHostedService<FirstHostedService>();
_module.Configure<HostedServiceRegisteringFeature>();
_module.Apply();
Assert.Equal([typeof(FirstHostedService), typeof(SelfRegisteredHostedService)], GetHostedServiceTypes());
}
private List<Type?> GetHostedServiceTypes()
{
return _services.Where(x => x.ServiceType == typeof(IHostedService)).Select(x => x.ImplementationType).ToList();
}
private IInstalledFeatureRegistry GetInstalledFeatureRegistry()
{
return (IInstalledFeatureRegistry)_services.Single(x => x.ServiceType == typeof(IInstalledFeatureRegistry)).ImplementationInstance!;
}
/// <summary>
/// Records the order in which features are applied so that tests can assert on it.
/// </summary>
public abstract class RecordingFeature(IModule module) : FeatureBase(module)
{
public override void Apply() => ((List<Type>)Module.Properties[AppliedFeaturesKey]).Add(GetType());
}
public class IntroducingFeature(IModule module) : RecordingFeature(module)
{
public override void Apply()
{
base.Apply();
Module.Configure<IntroducedFeature>();
}
}
public class IntroducedFeature(IModule module) : RecordingFeature(module)
{
public override void ConfigureHostedServices() => ConfigureHostedService<IntroducedHostedService>(3);
public override void Apply()
{
base.Apply();
Services.AddSingleton<IntroducedMarker>();
}
}
/// <summary>
/// Registers a hosted service straight with the service collection, the way <c>WorkflowRuntimeFeature</c> does, rather than through the module.
/// </summary>
public class HostedServiceRegisteringFeature(IModule module) : RecordingFeature(module)
{
public override void Apply()
{
base.Apply();
Services.AddHostedService<SelfRegisteredHostedService>();
}
}
public class ChainIntroducingFeature(IModule module) : RecordingFeature(module)
{
public override void Apply()
{
base.Apply();
Module.Configure<ChainMiddleFeature>();
}
}
public class ChainMiddleFeature(IModule module) : RecordingFeature(module)
{
public override void Apply()
{
base.Apply();
Module.Configure<ChainLeafFeature>();
}
}
public class ChainLeafFeature(IModule module) : RecordingFeature(module);
public class IntroducingDependentFeature(IModule module) : RecordingFeature(module)
{
public override void Apply()
{
base.Apply();
Module.Configure<IntroducedDependentFeature>();
}
}
[DependsOn(typeof(IntroducedDependencyFeature))]
public class IntroducedDependentFeature(IModule module) : RecordingFeature(module);
public class IntroducedDependencyFeature(IModule module) : RecordingFeature(module);
[DependsOn(typeof(DependencyFeature))]
public class DependentFeature(IModule module) : RecordingFeature(module);
public class DependencyFeature(IModule module) : RecordingFeature(module);
public class IntroducedMarker;
public abstract class NoopHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
public class IntroducedHostedService : NoopHostedService;
public class SelfRegisteredHostedService : NoopHostedService;
public class FirstHostedService : NoopHostedService;
public class SecondHostedService : NoopHostedService;
}