From 542058971b406c062cf2eccb8dd169f15b0fe3b2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 2 Aug 2026 01:59:45 +0200 Subject: [PATCH] Avoid repeated tenant-agnostic registry population --- .../Contracts/IActivityRegistry.cs | 21 ++- .../ITenantAgnosticActivityProvider.cs | 10 + .../Services/ActivityRegistry.cs | 64 +++++-- .../HostMethod/HostMethodActivityProvider.cs | 2 +- .../Contracts/IActivityRegistryPopulator.cs | 12 +- .../Providers/TypedActivityProvider.cs | 4 +- .../Services/ActivityRegistryPopulator.cs | 9 +- .../Services/DefaultRegistriesPopulator.cs | 14 +- .../Services/ActivityRegistryTests.cs | 175 ++++++++++++++++++ .../ActivityRegistryPopulatorTests.cs | 111 +++++++++++ .../DefaultRegistriesPopulatorTests.cs | 31 +++- 11 files changed, 419 insertions(+), 34 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/ITenantAgnosticActivityProvider.cs create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Services/ActivityRegistryPopulatorTests.cs diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityRegistry.cs index 03ef41876..ec9bde24d 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityRegistry.cs @@ -95,10 +95,21 @@ public interface IActivityRegistry : IActivityProvider /// /// Refreshes the activity descriptors in the registry by querying the specified activity provider. /// - Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default); - - /// - /// Clears all activity descriptors from the registry. + Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default); + + /// + /// Ensures that descriptors from a tenant-agnostic activity provider have been initialized. + /// + /// The activity provider used to retrieve the descriptors. + /// The cancellation token. + /// + /// Tenant-sensitive providers are refreshed on every call. The default implementation refreshes every provider to preserve compatibility with custom registry implementations. + /// + Task EnsureDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) => + RefreshDescriptorsAsync(activityProvider, cancellationToken); + + /// + /// Clears all activity descriptors from the registry. /// void Clear(); @@ -107,4 +118,4 @@ public interface IActivityRegistry : IActivityProvider /// /// The type of the activity provider. void ClearProvider(Type providerType); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Contracts/ITenantAgnosticActivityProvider.cs b/src/modules/Elsa.Workflows.Core/Contracts/ITenantAgnosticActivityProvider.cs new file mode 100644 index 000000000..e0b08d4a7 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/ITenantAgnosticActivityProvider.cs @@ -0,0 +1,10 @@ +namespace Elsa.Workflows; + +/// +/// Marks an activity provider whose descriptors do not depend on the current tenant. +/// +/// +/// Descriptors from a tenant-agnostic provider can be initialized once for each +/// instance. +/// +public interface ITenantAgnosticActivityProvider : IActivityProvider; diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs index d1b334b51..62bc72b0e 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityRegistry.cs @@ -16,9 +16,13 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Per-tenant activity descriptors (workflow-as-activities, tenant-specific providers, etc.) private readonly ConcurrentDictionary _tenantRegistries = new(); - // Tenant-agnostic activity descriptors (built-in activities, manually registered, etc.) - private readonly TenantRegistryData _agnosticRegistry = new(); - + // Tenant-agnostic activity descriptors (built-in activities, manually registered, etc.) + private readonly TenantRegistryData _agnosticRegistry = new(); + + // Tracks tenant-agnostic providers initialized for this registry instance. + private readonly ConcurrentDictionary _initializedProviders = new(); + private readonly ConcurrentDictionary _providerInitializationLocks = new(); + /// public void Add(Type providerType, ActivityDescriptor descriptor) { @@ -182,7 +186,7 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< await RefreshDescriptorsAsync(activityProvider, cancellationToken); } - public async Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) + public async Task RefreshDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) { var providerType = activityProvider.GetType(); @@ -216,9 +220,39 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< // Update the provider's descriptor list in this registry registry.ProvidedActivityDescriptors[providerType] = providerDescriptors; - } - } - + } + } + + /// + public async Task EnsureDescriptorsAsync(IActivityProvider activityProvider, CancellationToken cancellationToken = default) + { + if (activityProvider is not ITenantAgnosticActivityProvider) + { + await RefreshDescriptorsAsync(activityProvider, cancellationToken); + return; + } + + var providerType = activityProvider.GetType(); + if (_initializedProviders.ContainsKey(providerType)) + return; + + var initializationLock = _providerInitializationLocks.GetOrAdd(providerType, _ => new(1, 1)); + await initializationLock.WaitAsync(cancellationToken); + + try + { + if (_initializedProviders.ContainsKey(providerType)) + return; + + await RefreshDescriptorsAsync(activityProvider, cancellationToken); + _initializedProviders.TryAdd(providerType, 0); + } + finally + { + initializationLock.Release(); + } + } + private void Add(ActivityDescriptor? descriptor, TenantRegistryData registry, ICollection providerDescriptors) { if (descriptor is null) @@ -253,15 +287,17 @@ public class ActivityRegistry(IActivityDescriber activityDescriber, IEnumerable< { _manualActivityDescriptors.Clear(); _tenantRegistries.Clear(); - _agnosticRegistry.ActivityDescriptors.Clear(); - _agnosticRegistry.LatestActivityDescriptors.Clear(); - _agnosticRegistry.ProvidedActivityDescriptors.Clear(); - } + _agnosticRegistry.ActivityDescriptors.Clear(); + _agnosticRegistry.LatestActivityDescriptors.Clear(); + _agnosticRegistry.ProvidedActivityDescriptors.Clear(); + _initializedProviders.Clear(); + } /// - public void ClearProvider(Type providerType) - { - var currentTenantId = tenantAccessor.TenantId; + public void ClearProvider(Type providerType) + { + _initializedProviders.TryRemove(providerType, out _); + var currentTenantId = tenantAccessor.TenantId; // Clear from current tenant's registry if (_tenantRegistries.TryGetValue(currentTenantId, out var tenantRegistry) diff --git a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs index 34641f283..643c25df2 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs @@ -11,7 +11,7 @@ namespace Elsa.Workflows.Management.Activities.HostMethod; /// Inputs come from public properties and method parameters. /// [UsedImplicitly] -public class HostMethodActivityProvider(IOptions options, IHostMethodActivityDescriber hostMethodActivityDescriber) : IActivityProvider +public class HostMethodActivityProvider(IOptions options, IHostMethodActivityDescriber hostMethodActivityDescriber) : ITenantAgnosticActivityProvider { public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) { diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IActivityRegistryPopulator.cs b/src/modules/Elsa.Workflows.Management/Contracts/IActivityRegistryPopulator.cs index ba841171c..75964aa62 100644 --- a/src/modules/Elsa.Workflows.Management/Contracts/IActivityRegistryPopulator.cs +++ b/src/modules/Elsa.Workflows.Management/Contracts/IActivityRegistryPopulator.cs @@ -10,4 +10,14 @@ public interface IActivityRegistryPopulator /// /// The cancellation token. Task PopulateRegistryAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file + + /// + /// Ensures that activity descriptors have been populated, initializing tenant-agnostic providers once. + /// + /// The cancellation token. + /// + /// The default implementation preserves the existing behavior for custom populators. + /// + Task EnsureRegistryPopulatedAsync(CancellationToken cancellationToken = default) => + PopulateRegistryAsync(cancellationToken); +} diff --git a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs index 3ebba7b16..dd0ece8d7 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs @@ -10,7 +10,7 @@ namespace Elsa.Workflows.Management.Providers; /// Provides activity descriptors based on a list of activity types registered in the . /// [UsedImplicitly] -public class TypedActivityProvider : IActivityProvider +public class TypedActivityProvider : ITenantAgnosticActivityProvider { private readonly IActivityDescriber _activityDescriber; private readonly ManagementOptions _options; @@ -40,4 +40,4 @@ public class TypedActivityProvider : IActivityProvider yield return descriptor; } } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Management/Services/ActivityRegistryPopulator.cs b/src/modules/Elsa.Workflows.Management/Services/ActivityRegistryPopulator.cs index a00cb8559..5310f95a3 100644 --- a/src/modules/Elsa.Workflows.Management/Services/ActivityRegistryPopulator.cs +++ b/src/modules/Elsa.Workflows.Management/Services/ActivityRegistryPopulator.cs @@ -10,4 +10,11 @@ public class ActivityRegistryPopulator(IEnumerable providers, { await registry.RefreshDescriptorsAsync(providers, cancellationToken); } -} \ No newline at end of file + + /// + public async Task EnsureRegistryPopulatedAsync(CancellationToken cancellationToken) + { + foreach (var provider in providers) + await registry.EnsureDescriptorsAsync(provider, cancellationToken); + } +} diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultRegistriesPopulator.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultRegistriesPopulator.cs index 1ec647cb5..4db677c0d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultRegistriesPopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultRegistriesPopulator.cs @@ -14,16 +14,16 @@ public class DefaultRegistriesPopulator( /// public async Task PopulateAsync(CancellationToken cancellationToken = default) { - // Stage 1: Populate the activity registry. - // Because workflow definitions can be used as activities, we need to make sure that the activity registry is populated before we populate the workflow definition store. - await activityRegistryPopulator.PopulateRegistryAsync(cancellationToken); + // Stage 1: Ensure the activity registry is populated. + // Tenant-agnostic providers are initialized once, while tenant-sensitive providers are refreshed before the workflow definition store is populated. + await activityRegistryPopulator.EnsureRegistryPopulatedAsync(cancellationToken); // Stage 2: Populate the workflow definition store. await workflowDefinitionStorePopulator.PopulateStoreAsync(false, cancellationToken); - // Stage 3: Re-populate the activity registry. - // After the workflow definition store has been populated, we need to re-populate the activity registry to make sure that the activity descriptors are up-to-date. - await activityRegistryPopulator.PopulateRegistryAsync(cancellationToken); + // Stage 3: Refresh tenant-sensitive activity providers. + // After the workflow definition store has been populated, refresh tenant-specific descriptors such as workflow-as-activity definitions. + await activityRegistryPopulator.EnsureRegistryPopulatedAsync(cancellationToken); // Stage 4. Re-update the workflow definition store with the current set of activities. // Finally, we need to re-populate the workflow definition store to make sure that the workflow definitions are up-to-date. @@ -34,4 +34,4 @@ public class DefaultRegistriesPopulator( var notification = new WorkflowDefinitionsReloaded(reloadedWorkflowDefinitions); await notificationSender.SendAsync(notification, cancellationToken); } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs index 916971d1b..f51f5022c 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/ActivityRegistryTests.cs @@ -425,6 +425,112 @@ public class ActivityRegistryTests AssertDescriptor(result, CurrentTenant, 2); } + [Fact] + public async Task EnsureDescriptorsAsync_InitializesTenantAgnosticProviderOnlyOnce_WhenCalledRepeatedly() + { + // Arrange + var provider = new CountingTenantAgnosticProvider(); + + // Act + await _registry.EnsureDescriptorsAsync(provider); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(1, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_InitializesTenantAgnosticProviderOnlyOnce_WhenCalledConcurrently() + { + // Arrange + var provider = new BlockingTenantAgnosticProvider(); + var initializationTasks = Enumerable.Range(0, 8) + .Select(_ => _registry.EnsureDescriptorsAsync(provider)) + .ToArray(); + + await provider.FirstCallStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + await Task.Yield(); + provider.Release(); + + // Act + await Task.WhenAll(initializationTasks); + + // Assert + Assert.Equal(1, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_RetriesTenantAgnosticProvider_AfterFailure() + { + // Arrange + var provider = new CountingTenantAgnosticProvider([new InvalidOperationException("Expected failure")]); + + // Act + await Assert.ThrowsAsync(() => _registry.EnsureDescriptorsAsync(provider)); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_RetriesTenantAgnosticProvider_AfterCancellation() + { + // Arrange + var provider = new CountingTenantAgnosticProvider([new OperationCanceledException()]); + + // Act + await Assert.ThrowsAnyAsync(() => _registry.EnsureDescriptorsAsync(provider)); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_ReinitializesTenantAgnosticProvider_AfterRegistryIsCleared() + { + // Arrange + var provider = new CountingTenantAgnosticProvider(); + await _registry.EnsureDescriptorsAsync(provider); + + // Act + _registry.Clear(); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_ReinitializesTenantAgnosticProvider_AfterProviderIsCleared() + { + // Arrange + var provider = new CountingTenantAgnosticProvider(); + await _registry.EnsureDescriptorsAsync(provider); + + // Act + _registry.ClearProvider(provider.GetType()); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task EnsureDescriptorsAsync_RefreshesTenantSensitiveProvider_OnEveryCall() + { + // Arrange + var provider = new CountingTenantSensitiveProvider(); + + // Act + await _registry.EnsureDescriptorsAsync(provider); + await _registry.EnsureDescriptorsAsync(provider); + + // Assert + Assert.Equal(2, provider.CallCount); + } + private ActivityDescriptor CreateDescriptor(string typeName, int version, string? tenantId) => new() @@ -464,4 +570,73 @@ public class ActivityRegistryTests public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) => new(Descriptors); } + private sealed class CountingTenantAgnosticProvider(IEnumerable? failures = null) : ITenantAgnosticActivityProvider + { + private readonly Queue _failures = new(failures ?? []); + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + + if (_failures.TryDequeue(out var failure)) + return ValueTask.FromException>(failure); + + return new([new ActivityDescriptor + { + TypeName = nameof(CountingTenantAgnosticProvider), + Version = 1, + TenantId = Tenant.AgnosticTenantId, + Kind = ActivityKind.Action + }]); + } + } + + private sealed class BlockingTenantAgnosticProvider : ITenantAgnosticActivityProvider + { + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _callCount; + + public TaskCompletionSource FirstCallStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int CallCount => Volatile.Read(ref _callCount); + + public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + FirstCallStarted.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + + return [new ActivityDescriptor + { + TypeName = nameof(BlockingTenantAgnosticProvider), + Version = 1, + TenantId = Tenant.AgnosticTenantId, + Kind = ActivityKind.Action + }]; + } + + public void Release() => _release.TrySetResult(); + } + + private sealed class CountingTenantSensitiveProvider : IActivityProvider + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + return new([new ActivityDescriptor + { + TypeName = nameof(CountingTenantSensitiveProvider), + Version = 1, + TenantId = CurrentTenant, + Kind = ActivityKind.Action + }]); + } + } + } diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/ActivityRegistryPopulatorTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ActivityRegistryPopulatorTests.cs new file mode 100644 index 000000000..5256762bb --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ActivityRegistryPopulatorTests.cs @@ -0,0 +1,111 @@ +using Elsa.Common.Multitenancy; +using Elsa.Workflows.Management.Activities.HostMethod; +using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; +using Elsa.Workflows.Management.Providers; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Models; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Workflows.Management.UnitTests.Services; + +public class ActivityRegistryPopulatorTests +{ + [Fact] + public async Task EnsureRegistryPopulatedAsync_InitializesAgnosticProvidersOnceAcrossScopedPopulators_AndRefreshesTenantSensitiveProvidersPerPass() + { + // Arrange + var agnosticProviderCallCount = new CallCounter(); + var tenantSensitiveProviderCallCount = new CallCounter(); + var registry = CreateRegistry(); + IActivityRegistryPopulator firstScopePopulator = new ActivityRegistryPopulator( + [new CountingTenantAgnosticProvider(agnosticProviderCallCount), new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount)], registry); + IActivityRegistryPopulator secondScopePopulator = new ActivityRegistryPopulator( + [new CountingTenantAgnosticProvider(agnosticProviderCallCount), new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount)], registry); + + // Act + await firstScopePopulator.EnsureRegistryPopulatedAsync(); + await secondScopePopulator.EnsureRegistryPopulatedAsync(); + + // Assert + Assert.Equal(1, agnosticProviderCallCount.Value); + Assert.Equal(2, tenantSensitiveProviderCallCount.Value); + } + + [Fact] + public async Task PopulateRegistryAsync_ForceRefreshesAllProvidersOnEveryPass() + { + // Arrange + var agnosticProviderCallCount = new CallCounter(); + var tenantSensitiveProviderCallCount = new CallCounter(); + var agnosticProvider = new CountingTenantAgnosticProvider(agnosticProviderCallCount); + var tenantSensitiveProvider = new CountingTenantSensitiveProvider(tenantSensitiveProviderCallCount); + IActivityRegistryPopulator populator = new ActivityRegistryPopulator([agnosticProvider, tenantSensitiveProvider], CreateRegistry()); + + // Act + await populator.PopulateRegistryAsync(); + await populator.PopulateRegistryAsync(); + + // Assert + Assert.Equal(2, agnosticProviderCallCount.Value); + Assert.Equal(2, tenantSensitiveProviderCallCount.Value); + } + + [Fact] + public void BuiltInTenantAgnosticProviders_OptIntoOncePerRegistryPopulation() + { + Assert.True(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(TypedActivityProvider))); + Assert.True(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(HostMethodActivityProvider))); + Assert.False(typeof(ITenantAgnosticActivityProvider).IsAssignableFrom(typeof(WorkflowDefinitionActivityProvider))); + } + + private static ActivityRegistry CreateRegistry() + { + var tenantAccessor = Substitute.For(); + tenantAccessor.TenantId.Returns("tenant-1"); + return new ActivityRegistry( + Substitute.For(), + [], + tenantAccessor, + Substitute.For>()); + } + + private sealed class CountingTenantAgnosticProvider(CallCounter callCounter) : ITenantAgnosticActivityProvider + { + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + callCounter.Increment(); + return new([new ActivityDescriptor + { + TypeName = nameof(CountingTenantAgnosticProvider), + Version = 1, + TenantId = Tenant.AgnosticTenantId, + Kind = ActivityKind.Action + }]); + } + } + + private sealed class CountingTenantSensitiveProvider(CallCounter callCounter) : IActivityProvider + { + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + callCounter.Increment(); + return new([new ActivityDescriptor + { + TypeName = nameof(CountingTenantSensitiveProvider), + Version = 1, + TenantId = "tenant-1", + Kind = ActivityKind.Action + }]); + } + } + + private sealed class CallCounter + { + private int _value; + + public int Value => Volatile.Read(ref _value); + + public void Increment() => Interlocked.Increment(ref _value); + } +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/DefaultRegistriesPopulatorTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/DefaultRegistriesPopulatorTests.cs index 938c2e3c4..17b52bac3 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/DefaultRegistriesPopulatorTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Services/DefaultRegistriesPopulatorTests.cs @@ -9,18 +9,19 @@ namespace Elsa.Workflows.Runtime.UnitTests.Services; public class DefaultRegistriesPopulatorTests { private readonly IWorkflowDefinitionStorePopulator _workflowDefinitionStorePopulatorMock; + private readonly IActivityRegistryPopulator _activityRegistryPopulatorMock; private readonly INotificationSender _notificationSenderMock; private readonly DefaultRegistriesPopulator _populator; public DefaultRegistriesPopulatorTests() { _workflowDefinitionStorePopulatorMock = Substitute.For(); - var activityRegistryPopulatorMock = Substitute.For(); + _activityRegistryPopulatorMock = Substitute.For(); _notificationSenderMock = Substitute.For(); _populator = new( _workflowDefinitionStorePopulatorMock, - activityRegistryPopulatorMock, + _activityRegistryPopulatorMock, _notificationSenderMock); } @@ -56,4 +57,28 @@ public class DefaultRegistriesPopulatorTests Arg.Is(n => n.ReloadedWorkflowDefinitions.Count == 2), Arg.Any()); } -} \ No newline at end of file + + [Fact] + public async Task PopulateAsync_EnsuresActivityRegistryBeforeEachWorkflowDefinitionStorePopulation() + { + // Arrange + _workflowDefinitionStorePopulatorMock + .PopulateStoreAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + // Act + await _populator.PopulateAsync(); + + // Assert + Received.InOrder(() => + { + _activityRegistryPopulatorMock.EnsureRegistryPopulatedAsync(Arg.Any()); + _workflowDefinitionStorePopulatorMock.PopulateStoreAsync(false, Arg.Any()); + _activityRegistryPopulatorMock.EnsureRegistryPopulatedAsync(Arg.Any()); + _workflowDefinitionStorePopulatorMock.PopulateStoreAsync(true, Arg.Any()); + _notificationSenderMock.SendAsync(Arg.Any(), Arg.Any()); + }); + + await _activityRegistryPopulatorMock.Received(2).EnsureRegistryPopulatedAsync(Arg.Any()); + } +}