diff --git a/Directory.Packages.props b/Directory.Packages.props index 232f8c561..2c181897c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,174 +9,175 @@ 9.9.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index f92369873..ba08d1d2c 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -311,6 +311,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "qa", "qa", "{0478E6EA-DCB2- doc\qa\test-guidelines.md = doc\qa\test-guidelines.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Management.UnitTests", "test\unit\Elsa.Workflows.Management.UnitTests\Elsa.Workflows.Management.UnitTests.csproj", "{20C75446-CAE0-48D1-85D9-459541C4D8B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests", "test\unit\Elsa.Expressions.UnitTests\Elsa.Expressions.UnitTests.csproj", "{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -619,6 +623,14 @@ Global {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.Build.0 = Release|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.Build.0 = Release|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -714,6 +726,8 @@ Global {DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92} {2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA} = {18453B51-25EB-4317-A4B3-B10518252E92} {0478E6EA-DCB2-4667-ADC2-37C62C9C2574} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92} + {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index d2fa07e7b..de0b29cc1 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -45,7 +45,7 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o ## Quick Start for Contributors -**Before you writ e a test:** +**Before you write a test:** 1. ✅ Understand what you're testing (see [**Which parts of Elsa to test**](#which-parts-of-elsa-to-test-and-which-test-types-to-use)) 2. ✅ Choose the right test layer (unit vs integration vs component) 3. ✅ Use existing helpers (don't reinvent - see [**Test Helpers Reference**](#test-helpers-reference-quick-lookup)) @@ -68,6 +68,24 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o --- +## Test Project Organization + +Tests are organized by module and test type: + +- **`test/unit/Elsa.Activities.UnitTests`** - Activity unit tests +- **`test/unit/Elsa.Expressions.UnitTests`** - Expression evaluation unit tests +- **`test/unit/Elsa.Workflows.Core.UnitTests`** - Core workflow unit tests (ActivityExecutionContext extensions, etc.) +- **`test/unit/Elsa.Workflows.Management.UnitTests`** - Workflow management unit tests +- **`test/integration/Elsa.Workflows.IntegrationTests`** - Workflow integration tests +- **`test/component/Elsa.Workflows.ComponentTests`** - End-to-end component tests + +**Shared test infrastructure:** +- **`src/common/Elsa.Testing.Shared`** - Shared test helpers (`ActivityTestFixture`, fake strategies, extensions) +- **`src/common/Elsa.Testing.Shared.Integration`** - Integration test helpers (`RunActivityExtensions`, `PopulateRegistriesAsync`) +- **`src/common/Elsa.Testing.Shared.Component`** - Component test helpers (event handlers, workflow events) + +--- + ## Which parts of Elsa to test, and which test types to use This section maps Elsa aspects to the exact kinds of tests you should write, with examples and code patterns referencing repository conventions. @@ -76,8 +94,9 @@ This section maps Elsa aspects to the exact kinds of tests you should write, wit #### **Unit tests:** - Test the activity class logic only (no persistence, no scheduler). Cover configuration permutations and boundary inputs. -- Use [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs), `ExecuteActivityAsync` method to run the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions. -- In case the activity sets outputs, assert using `context.GetExecutionOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)), otherwise, use `context.Get()`. +- Use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) to configure and execute the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions. +- Use fluent methods: `ConfigureServices()` to add custom services, `ConfigureContext()` to set up context state +- Assert using `context.GetActivityOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)) for outputs, or `context.Get()` for variables. **Example (does not set output):** ```csharp @@ -90,7 +109,8 @@ public async Task Should_Set_Variable_Integer() var setVariable = new SetVariable(variable, new Input(expected)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var fixture = new ActivityTestFixture(setVariable); + var context = await fixture.ExecuteAsync(); // Assert var result = context.Get(variable); @@ -98,39 +118,56 @@ public async Task Should_Set_Variable_Integer() } ``` -**Example (sets output):** +**Example (sets output using fluent configuration):** ```csharp [Fact] - public async Task Should_Send_GET_Request_And_Handle_Success_Response() +public async Task Should_Send_Get_Request_And_Handle_Success_Response() +{ + // Arrange + var expectedUrl = new Uri("https://example.com"); + var sendHttpRequest = new SendHttpRequest { - // Arrange - // Extra arrange steps omitted for brevity - var sendHttpRequest = new SendHttpRequest - { - Url = new Input(expectedUrl), - Method = new Input("GET"), - ExpectedStatusCodes = new List() - }; + Url = new Input(expectedUrl), + Method = new Input("GET") + }; - // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(sendHttpRequest, services => - { - // http configuration omitted for brevity - }); + // Act + var fixture = new ActivityTestFixture(sendHttpRequest) + .WithHttpServices(responseHandler); // HTTP-specific extension + var context = await fixture.ExecuteAsync(); - // Assert - var statusCodeOutput = context.GetExecutionOutput(_ => sendHttpRequest.StatusCode); - Assert.Equal(200, statusCodeOutput); - } + // Assert + var statusCodeOutput = context.GetActivityOutput(_ => sendHttpRequest.StatusCode); + Assert.Equal(200, statusCodeOutput); +} +``` + +**Example (checking scheduled activities):** +```csharp +[Fact] +public async Task Should_Schedule_Child_Activity() +{ + // Arrange + var childActivity = new WriteLine("Hello"); + var parentActivity = new Sequence { Activities = [childActivity] }; + + // Act + var fixture = new ActivityTestFixture(parentActivity); + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(childActivity)); +} ``` #### **Integration tests:** - Place the activity inside a minimal workflow definition and run via [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs). Assert outputs/variables and that the activity integrates correctly with preceding/following activities. - If activity creates bookmarks or relies on scheduler semantics, integration tests should resume bookmarks via the engine APIs to validate resumption. -Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion. +Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion. Use returned state for deterministic assertions where possible. +--- ### Workflow execution (invoker, middleware, bookmarks) @@ -167,14 +204,38 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status); ## Test Helpers Reference (Quick Lookup) -| Helper | Purpose | Use When | -|-------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------| -| `TestApplicationBuilder` | Build test service provider | All tests as entry point,
except activities unit tests (see below) | -| `ActivityTestHelper.ExecuteActivityAsync` | Run single activity, with isolated context | Unit testing activities | -| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests | -| `PopulateRegistriesAsync` | Register types for JSON deserialization. | Loading JSON workflows.
Integration tests only | -| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) | -| `RunWorkflowUntilEndAsync` | Drive workflow to completion. | Complex resumption scenarios.
Integration tests only | +| Helper | Purpose | Use When | +|-------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------| +| `TestApplicationBuilder` | Build test service provider | All tests as entry point,
except activities unit tests (see below) | +| `ActivityTestFixture` | Configure and run single activity in isolation | Unit testing activities | +| `ActivityTestFixture.ConfigureServices` | Fluent API to add services to fixture | Activity unit tests requiring custom services | +| `ActivityTestFixture.ConfigureContext` | Fluent API to configure execution context before execution | Setting up test-specific context state | +| `ActivityTestFixture.BuildAsync` | Build context without executing activity | When you need context setup without execution | +| `ActivityTestFixture.ExecuteAsync` | Execute activity and return context | Standard activity unit test execution | +| `context.GetActivityOutput` | Get output from activity using expression selector | Asserting activity outputs in unit tests | +| `context.HasScheduledActivity` | Check if activity is scheduled | Verifying scheduling behavior in unit tests | +| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests | +| `RunActivityExtensions.RunActivityAsync` | Run single activity as workflow | Integration tests for single activities | +| `PopulateRegistriesAsync` | Register types for JSON deserialization | Loading JSON workflows.
Integration tests only | +| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) | +| `AsyncWorkflowRunner.RunAndAwaitWorkflowCompletionAsync` | Drive workflow to completion with activity tracking | Complex async scenarios.
Component tests only | +| `FakeActivityExecutionContextSchedulerStrategy` | Fake scheduler for unit tests | Automatically configured in `ActivityTestFixture` | +| `FakeWorkflowExecutionContextSchedulerStrategy` | Fake workflow scheduler for unit tests | Automatically configured in `ActivityTestFixture` | + +--- + +## Scheduler Strategies for Testing + +When testing activities in isolation, Elsa provides fake scheduler strategy implementations that bypass the production scheduling logic: + +### Available Strategies + +- **`FakeActivityExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately within the activity execution context for deterministic testing +- **`FakeWorkflowExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately at the workflow level for deterministic testing + +These strategies are automatically configured when using [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) and ensure that scheduled activities execute synchronously in tests, making assertions deterministic. + +**Note:** You do not need to manually configure these strategies - `ActivityTestFixture` handles this for you. --- @@ -198,9 +259,9 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu ## Deterministic patterns to avoid flaky tests -1. **For activities unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. +1. **For activity unit tests, prefer returned state from [`ActivityTestFixture.ExecuteAsync`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows. 2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution. -3. **For integration tests, Locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. +3. **For integration tests, locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries. --- @@ -210,7 +271,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu --- -## Practical test recipes & snippets (copy/paste-ready) +## Practical test recipes & snippets ### Unit test (activity) — pattern @@ -221,7 +282,8 @@ public async Task MyActivity_Test() var activity = new ActivityToTest(); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(activity); + var fixture = new ActivityTestFixture(activity); + var context = await fixture.ExecuteAsync(); // assert behavior of activity in isolation } @@ -255,10 +317,35 @@ public async Task Workflow_Persists_Instance_And_Journal() .Build(); var runner = sp.GetRequiredService(); -var result = await runner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published)); + var result = await runner.RunAndAwaitWorkflowCompletionAsync( + WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published) + ); result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished); } ``` + +### Component test with async workflow execution + +For testing workflows that complete asynchronously (e.g., with timers, external triggers), use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs): + +```csharp +[Fact] +public async Task Workflow_Completes_Asynchronously() +{ + var sp = new TestApplicationBuilder(testOutput).Build(); + var runner = sp.GetRequiredService(); + + var result = await runner.RunAndAwaitWorkflowCompletionAsync( + WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published) + ); + + result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished); + result.ActivityExecutionRecords.Should().HaveCount(expectedCount); +} +``` + +`AsyncWorkflowRunner` tracks activity execution records and awaits workflow completion signals, making it ideal for testing asynchronous workflow behavior deterministically. + --- ## FAQ (quick pointers) @@ -271,7 +358,7 @@ Leave the definitions next to the tests that use them. **Q: Which helper should I use to run a workflow?** -A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use `ExecuteActivityAsync` via [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs). +A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs). **Q: How do I check persisted journal entries?** @@ -279,7 +366,7 @@ A: Query [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/ **Q: Do I need a new helper to wait for workflow completion?** -A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. +A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. For async workflows in component tests, use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs). --- @@ -287,9 +374,9 @@ A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Cont Search the `test/` tree for examples that follow the above patterns: -- Unit test activity examples: `test/unit/Elsa.Activities.UnitTests` (look for [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) usage). -- Integration workflow examples: `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage). -- Component scenarios exercising persistence: `test/component/Elsa.Workflows.ComponentTests (look for [`AppComponentTest`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs) scaffolds and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions). +- **Unit test activity examples:** `test/unit/Elsa.Activities.UnitTests` (look for [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) usage) +- **Integration workflow examples:** `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage) +- **Component scenarios exercising persistence:** `test/component/Elsa.Workflows.ComponentTests` (look for [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs) and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions) diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs new file mode 100644 index 000000000..fc5f5bff3 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.Workflows; + +namespace Elsa.Testing.Shared; + +/// +/// Provides extension methods for to enhance its functionality. +/// +public static class ActivityExecutionContextExtensions +{ + /// + /// Determines whether the specified activity is scheduled in the workflow execution context. + /// + /// The activity execution context instance used for checking the scheduled activity. + /// The activity to check for scheduling. + /// True if the specified activity is scheduled; otherwise, false. + public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity) + { + return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null; + } +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs new file mode 100644 index 000000000..ab1b7dbb1 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -0,0 +1,174 @@ +using Elsa.Common; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Services; +using Elsa.Extensions; +using Elsa.Http.ContentWriters; +using Elsa.Http.Parsers; +using Elsa.Mediator.Contracts; +using Elsa.Resilience; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Management.Providers; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Memory; +using Elsa.Workflows.PortResolvers; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Elsa.Http; + +namespace Elsa.Testing.Shared; + +/// +/// A test fixture for unit testing activities in isolation. +/// Provides a fluent API to configure services, variables, and execution context. +/// +public class ActivityTestFixture +{ + private Action? _configureContextAction; + + /// + /// Initializes a new instance of the class. + /// + /// The activity to test + public ActivityTestFixture(IActivity activity) + { + Activity = activity; + Services = new ServiceCollection(); + AddCoreWorkflowServices(Services); + } + + /// + /// Represents the activity being tested within the context of the activity test fixture. + /// Provides access to the activity for configuration, execution, and validation purposes. + /// + public IActivity Activity { get; } + + /// + /// Gets the service collection for registering additional services. + /// Use this to add services required by the activity under test. + /// + [UsedImplicitly] + public IServiceCollection Services { get; private set; } + + /// + /// Configures the service collection using a fluent action. + /// + /// Action to configure the service collection + /// The fixture instance for method chaining + public ActivityTestFixture ConfigureServices(Action configure) + { + configure(Services); + return this; + } + + /// + /// Configures the activity execution context before execution. + /// Multiple calls to this method will chain the configuration actions together. + /// + /// Action to configure the activity execution context + /// The fixture instance for method chaining + [UsedImplicitly] + public ActivityTestFixture ConfigureContext(Action configure) + { + _configureContextAction += configure; + return this; + } + + /// + /// Executes the activity and returns the execution context. + /// + /// The ActivityExecutionContext after execution + public async Task ExecuteAsync() + { + var context = await BuildAsync(); + + // Set up variables and inputs, then execute the activity + await SetupExistingVariablesAsync(Activity, context); + await context.EvaluateInputPropertiesAsync(); + await Activity.ExecuteAsync(context); + + return context; + } + + /// + /// Builds the ActivityExecutionContext without executing the activity. + /// + public async Task BuildAsync() + { + var serviceProvider = Services.BuildServiceProvider(); + var activityRegistry = serviceProvider.GetRequiredService(); + var workflowGraphBuilder = serviceProvider.GetRequiredService(); + + await activityRegistry.RegisterAsync(Activity.GetType()); + + var workflow = Workflow.FromActivity(Activity); + var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow); + + // Create workflow execution context using the static factory method + var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync( + serviceProvider, + workflowGraph, + $"test-instance-{Guid.NewGuid()}", + CancellationToken.None + ); + + // Create ActivityExecutionContext for the actual activity we want to test + var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity); + + // Apply any context configuration action + _configureContextAction?.Invoke(context); + + return context; + } + + /// + /// Sets up existing variables found on the activity in the execution context. + /// This is necessary because in unit tests, variables need to be initialized. + /// + private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context) + { + var activityType = activity.GetType(); + var variableProperties = activityType.GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>)) + .ToList(); + + foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) + { + variable.Set(context.ExpressionExecutionContext, variable.Value); + } + + return Task.CompletedTask; + } + + private static void AddCoreWorkflowServices(IServiceCollection services) + { + services.AddLogging(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(_ => Substitute.For()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs new file mode 100644 index 000000000..6d473e011 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixtureExtensions.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Xunit; + +namespace Elsa.Testing.Shared; + +/// +/// General extension methods for ActivityTestFixture. +/// +public static class ActivityTestFixtureExtensions +{ + /// + /// Validates that an activity has the expected attribute configuration. + /// This is useful for ensuring activities are properly decorated with metadata. + /// + /// The test fixture (unused but enables extension method syntax) + /// Expected namespace (e.g., "Elsa") + /// /// Expected activity kind + /// Expected category (e.g., "HTTP") + /// Expected display name + /// Expected description + public static void AssertActivityAttributes( + this ActivityTestFixture fixture, + string expectedNamespace, + ActivityKind expectedKind, + string? expectedCategory = null, + string? expectedDisplayName = null, + string? expectedDescription = null + ) + { + var activityType = fixture.Activity.GetType(); + var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false) + .Cast().FirstOrDefault(); + + Assert.NotNull(activityAttribute); + Assert.Equal(expectedNamespace, activityAttribute.Namespace); + Assert.Equal(expectedKind, activityAttribute.Kind); + + if (expectedCategory != null) Assert.Equal(expectedCategory, activityAttribute.Category); + if (expectedDescription != null) Assert.Equal(expectedDescription, activityAttribute.Description); + if (expectedDisplayName != null) Assert.Equal(expectedDisplayName, activityAttribute.DisplayName); + } +} diff --git a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj index 812045425..89c9b383e 100644 --- a/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj +++ b/src/common/Elsa.Testing.Shared/Elsa.Testing.Shared.csproj @@ -9,7 +9,9 @@ + + diff --git a/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..c8bfeb541 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,46 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Testing.Shared; + +/// +/// Provides a fake implementation of the interface, +/// designed for testing purposes. This class allows scheduling of activities within an activity execution context +/// and enables control over workflows in a simulated environment. +/// +public class FakeActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy +{ + /// + /// Schedules the provided activity for execution within the given activity execution context. + /// + /// The current activity execution context in which to schedule the activity. + /// The activity to be scheduled. Can be null, in which case no operation is performed. + /// The owner activity execution context, if any. + /// Optional scheduling options to influence how the activity is scheduled. + public Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) + { + if (activity == null) + return Task.CompletedTask; + + var activityNode = new ActivityNode(activity!, ""); + return ScheduleActivityAsync(context, activityNode, owner, options); + } + + /// + /// Schedules the provided activity for execution within the specified activity execution context using the given scheduling strategy. + /// + /// The activity execution context in which the activity will be scheduled. + /// The activity node to be scheduled. If null, no operation is performed. + /// The owner activity execution context, if any, that triggered the scheduling request. + /// Optional scheduling parameters to customize the scheduling behavior. + public Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + { + if (activityNode == null) + return Task.CompletedTask; + + context.WorkflowExecutionContext.Schedule(activityNode!, owner ?? context, options); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..3604678ad --- /dev/null +++ b/src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Testing.Shared; + +/// +/// Bypasses validation and schedules the activity immediately. +/// +public class FakeWorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy +{ + /// + /// Schedules an activity within the workflow execution context. + /// + /// The workflow execution context within which the activity will be scheduled. + /// The activity node representing the activity to be scheduled. + /// The execution context owning the activity execution. + /// Optional scheduling options for configuring the behavior of the scheduling process. + /// Returns an representing the scheduled activity work item. + public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null) + { + var workItem = new ActivityWorkItem(activityNode.Activity, owner); + context.Scheduler.Schedule(workItem); + return workItem; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs index a7c8092e6..4a4f24811 100644 --- a/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs +++ b/src/modules/Elsa.Expressions/Services/ExpressionEvaluator.cs @@ -4,37 +4,25 @@ using Elsa.Expressions.Models; namespace Elsa.Expressions.Services; /// -public class ExpressionEvaluator : IExpressionEvaluator +public class ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) : IExpressionEvaluator { - private readonly IExpressionDescriptorRegistry _registry; - private readonly IServiceProvider _serviceProvider; - - /// - /// Initializes a new instance of the class. - /// - public ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) - { - _registry = registry; - _serviceProvider = serviceProvider; - } - /// - public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + public async ValueTask EvaluateAsync(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null) { return (T?)await EvaluateAsync(expression, typeof(T), context, options); } /// - public async ValueTask EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + public async ValueTask EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null) { var expressionType = expression.Type; - var expressionDescriptor = _registry.Find(expressionType); + var expressionDescriptor = registry.Find(expressionType); if (expressionDescriptor == null) - throw new Exception($"Could not find an descriptor for expression type \"{expressionType}\"."); + throw new($"Could not find a descriptor for expression type \"{expressionType}\"."); - var handler = expressionDescriptor.HandlerFactory(_serviceProvider); - options ??= new ExpressionEvaluatorOptions(); + var handler = expressionDescriptor.HandlerFactory(serviceProvider); + options ??= new(); return await handler.EvaluateAsync(expression, returnType, context, options); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs b/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs index a33bc161a..61de5f867 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Parallel.cs @@ -27,9 +27,16 @@ public class Parallel : Container /// protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) { + // If there are no activities, complete immediately + if (Activities.Count == 0) + { + await context.CompleteActivityAsync(); + return; + } + context.SetProperty(ScheduledChildrenProperty, Activities.Count); - - foreach (var activity in Activities) + + foreach (var activity in Activities) await context.ScheduleActivityAsync(activity, OnChildCompleted); } diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 2681afe22..0b2f642ad 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -314,10 +314,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The options used to schedule the activity. public async ValueTask ScheduleActivityAsync(IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) { - var activityNode = activity != null - ? WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.") - : null; - await ScheduleActivityAsync(activityNode, owner, options); + var schedulerStrategy = GetRequiredService(); + await schedulerStrategy.ScheduleActivityAsync(this, activity, owner, options); } /// @@ -326,55 +324,10 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The activity node to schedule. /// The activity execution context that owns the scheduled activity. /// The options used to schedule the activity. - public async ValueTask ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + public async Task ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) { - if (this.GetIsBackgroundExecution()) - { - // Validate that the specified activity is part of the workflow. - if (activityNode != null && !WorkflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) - throw new InvalidOperationException("The specified activity is not part of the workflow."); - - var scheduledActivity = new ScheduledActivity - { - ActivityNodeId = activityNode?.NodeId, - OwnerActivityInstanceId = owner?.Id, - Options = options != null - ? new ScheduledActivityOptions - { - CompletionCallback = options?.CompletionCallback?.Method.Name, - Tag = options?.Tag, - ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id, - PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false, - Variables = options?.Variables?.ToList(), - Input = options?.Input - } - : null - }; - - var scheduledActivities = this.GetBackgroundScheduledActivities().ToList(); - scheduledActivities.Add(scheduledActivity); - this.SetBackgroundScheduledActivities(scheduledActivities); - return; - } - - var completionCallback = options?.CompletionCallback; - owner ??= this; - - if (activityNode == null) - { - if (completionCallback != null) - { - Tag = options?.Tag; - var completedContext = new ActivityCompletedContext(this, this); - await completionCallback(completedContext); - } - else - await owner.CompleteActivityAsync(); - - return; - } - - WorkflowExecutionContext.Schedule(activityNode, owner, options); + var schedulerStrategy = GetRequiredService(); + await schedulerStrategy.ScheduleActivityAsync(this, activityNode, owner, options); } /// diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..959b8a540 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,36 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +/// Defines a strategy for scheduling activities in a workflow execution context. +/// +public interface IActivityExecutionContextSchedulerStrategy +{ + /// + /// Schedules an activity for execution based on the specified context, activity, owner, and scheduling options. + /// + /// The execution context of the activity. + /// The activity to be scheduled. Can be null if no specific activity is targeted. + /// The context of the owning activity, if applicable. Can be null. + /// Optional scheduling options for the activity. + Task ScheduleActivityAsync( + ActivityExecutionContext context, + IActivity? activity, + ActivityExecutionContext? owner, + ScheduleWorkOptions? options = null); + + /// + /// Schedules an activity node for execution within the specified activity execution context. + /// + /// The execution context in which the activity node will be scheduled. + /// The activity node to be scheduled. Can be null if no specific node is targeted. + /// The context of the owning activity execution, if applicable. Can be null. + /// Optional settings for scheduling the activity node. + Task ScheduleActivityAsync( + ActivityExecutionContext context, + ActivityNode? activityNode, + ActivityExecutionContext? owner = null, + ScheduleWorkOptions? options = null); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..e95f82ca9 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +/// Defines a strategy interface for scheduling activities within the context of a workflow execution. +/// +public interface IWorkflowExecutionContextSchedulerStrategy +{ + /// + /// Schedules an in the context of the provided workflow execution. + /// + /// The execution context of the workflow in which the activity is being scheduled. + /// The node representing the activity to be scheduled. + /// The that owns the scheduled activity. + /// Optional parameters for scheduling the work item. + /// The scheduled . + ActivityWorkItem Schedule(WorkflowExecutionContext context, + ActivityNode activityNode, + ActivityExecutionContext owner, + ScheduleWorkOptions? options = null); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index da83e9f01..501d08122 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -459,7 +459,15 @@ public static partial class ActivityExecutionContextExtensions return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; } - public static object? GetExecutionOutput(this ActivityExecutionContext context, Expression> property) + /// + /// Gets the output of an activity even when the context is not the same as the activity's context. + /// This is useful when you want to get the output of a previously executed activity. + /// + /// + /// + /// + /// + public static object? GetActivityOutput(this ActivityExecutionContext context, Expression> property) { if (property.Body is not MemberExpression memberExpr) { @@ -467,10 +475,10 @@ public static partial class ActivityExecutionContextExtensions } var propertyName = memberExpr.Member.Name; - - // Treat both null and 0 (for numeric types) as "unset" values. + var value = context.Get(propertyName); - if (value != null && !(value is int i && i == 0) && !(value is long l && l == 0L) && !(value is double d && d == 0.0) && !(value is float f && f == 0.0f)) + + if (value != null) return value; var registry = context.WorkflowExecutionContext.GetActivityOutputRegister(); @@ -478,6 +486,6 @@ public static partial class ActivityExecutionContextExtensions return registry.FindOutputByActivityInstanceId(context.Id, propertyName); } - internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; - internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; + public static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; + public static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index f025ef74d..c89f61bdc 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -109,30 +109,8 @@ public static class WorkflowExecutionContextExtensions ActivityExecutionContext owner, ScheduleWorkOptions? options = null) { - // Validate that the specified activity is part of the workflow. - if (!workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) - throw new InvalidOperationException("The specified activity is not part of the workflow."); - - var scheduler = workflowExecutionContext.Scheduler; - - if (options?.PreventDuplicateScheduling == true) - { - // Check if the activity is already scheduled for the specified owner. - var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner); - - if (existingWorkItem != null) - return existingWorkItem; - } - - var activity = activityNode.Activity; - var tag = options?.Tag; - var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input); - var completionCallback = options?.CompletionCallback; - - workflowExecutionContext.AddCompletionCallback(owner, activityNode, completionCallback, tag); - scheduler.Schedule(workItem); - - return workItem; + var schedulerStrategy = workflowExecutionContext.GetRequiredService(); + return schedulerStrategy.Schedule(workflowExecutionContext, activityNode, owner, options); } /// diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 9a725a410..6fde09cbf 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -170,6 +170,8 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddSingleton() + .AddSingleton() .AddScoped(CommitStateHandler) .AddSingleton() .AddSingleton() diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..25e8d2160 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityExecutionContextSchedulerStrategy.cs @@ -0,0 +1,71 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +public class ActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy +{ + /// + public async Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null) + { + var activityNode = activity != null + ? context.WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.") + : null; + await ScheduleActivityAsync(context, activityNode, owner, options); + } + + /// + public async Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null) + { + var workflowExecutionContext = context.WorkflowExecutionContext; + if (context.GetIsBackgroundExecution()) + { + // Validate that the specified activity is part of the workflow. + if (activityNode != null && !workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) + throw new InvalidOperationException("The specified activity is not part of the workflow."); + + var scheduledActivity = new ScheduledActivity + { + ActivityNodeId = activityNode?.NodeId, + OwnerActivityInstanceId = owner?.Id, + Options = options != null + ? new ScheduledActivityOptions + { + CompletionCallback = options?.CompletionCallback?.Method.Name, + Tag = options?.Tag, + ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id, + PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false, + Variables = options?.Variables?.ToList(), + Input = options?.Input + } + : null + }; + + var scheduledActivities = context.GetBackgroundScheduledActivities().ToList(); + scheduledActivities.Add(scheduledActivity); + context.SetBackgroundScheduledActivities(scheduledActivities); + return; + } + + var completionCallback = options?.CompletionCallback; + owner ??= context; + + if (activityNode == null) + { + if (completionCallback != null) + { + context.Tag = options?.Tag; + var completedContext = new ActivityCompletedContext(context, context); + await completionCallback(completedContext); + } + else + await owner.CompleteActivityAsync(); + + return; + } + + workflowExecutionContext.Schedule(activityNode, owner, options); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs new file mode 100644 index 000000000..7142cbcd3 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowExecutionContextSchedulerStrategy.cs @@ -0,0 +1,37 @@ +using Elsa.Workflows.Models; +using Elsa.Workflows.Options; + +namespace Elsa.Workflows; + +/// +public class WorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy +{ + /// + public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null) + { + // Validate that the specified activity is part of the workflow. + if (!context.NodeActivityLookup.ContainsKey(activityNode.Activity)) + throw new InvalidOperationException("The specified activity is not part of the workflow."); + + var scheduler = context.Scheduler; + + if (options?.PreventDuplicateScheduling == true) + { + // Check if the activity is already scheduled for the specified owner. + var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner); + + if (existingWorkItem != null) + return existingWorkItem; + } + + var activity = activityNode.Activity; + var tag = options?.Tag; + var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input); + var completionCallback = options?.CompletionCallback; + + context.AddCompletionCallback(owner, activityNode, completionCallback, tag); + scheduler.Schedule(workItem); + + return workItem; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs new file mode 100644 index 000000000..bfcec4cac --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs @@ -0,0 +1,189 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Xunit.Abstractions; +using Parallel = Elsa.Workflows.Activities.Parallel; + +namespace Elsa.Activities.IntegrationTests; + +public class ParallelTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _serviceProvider; + + public ParallelTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + } + + [Fact(DisplayName = "Parallel executes all child activities and completes")] + public async Task Parallel_ExecutesAllChildren_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(3, _capturingTextWriter.Lines.Count); + Assert.Contains("Activity 1", _capturingTextWriter.Lines); + Assert.Contains("Activity 2", _capturingTextWriter.Lines); + Assert.Contains("Activity 3", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel completes when empty")] + public async Task Parallel_Completes_WhenEmpty() + { + // Arrange + var parallel = new Parallel(); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Empty(_capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel executes single child activity and completes")] + public async Task Parallel_ExecutesSingleChild_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Single Activity") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Single(_capturingTextWriter.Lines); + Assert.Equal("Single Activity", _capturingTextWriter.Lines.Single()); + } + + [Fact(DisplayName = "Parallel executes multiple different activity types")] + public async Task Parallel_ExecutesMixedActivityTypes_AndCompletes() + { + // Arrange + var parallel = new Parallel( + new WriteLine("First"), + new SetName("TestName"), + new WriteLine("Second") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(2, _capturingTextWriter.Lines.Count); + Assert.Contains("First", _capturingTextWriter.Lines); + Assert.Contains("Second", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel completes only after all children complete")] + public async Task Parallel_CompletesOnlyAfterAllChildrenComplete() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Child 1"), + new WriteLine("Child 2"), + new WriteLine("Child 3"), + new WriteLine("Child 4") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Completed, parallelContext.Status); + Assert.Equal(4, _capturingTextWriter.Lines.Count); + } + + [Fact(DisplayName = "Parallel executes nested Parallel activities")] + public async Task Parallel_ExecutesNestedParallel_AndCompletes() + { + // Arrange + var innerParallel = new Parallel( + new WriteLine("Inner 1"), + new WriteLine("Inner 2") + ); + + var outerParallel = new Parallel( + new WriteLine("Outer 1"), + innerParallel, + new WriteLine("Outer 2") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(outerParallel); + + // Assert + var journal = result.Journal; + var outerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == outerParallel); + var innerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == innerParallel); + + Assert.NotNull(outerParallelContext); + Assert.NotNull(innerParallelContext); + Assert.Equal(ActivityStatus.Completed, outerParallelContext.Status); + Assert.Equal(ActivityStatus.Completed, innerParallelContext.Status); + Assert.Equal(4, _capturingTextWriter.Lines.Count); + Assert.Contains("Outer 1", _capturingTextWriter.Lines); + Assert.Contains("Outer 2", _capturingTextWriter.Lines); + Assert.Contains("Inner 1", _capturingTextWriter.Lines); + Assert.Contains("Inner 2", _capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Parallel remains in Running state when a child activity faults")] + public async Task Parallel_RemainsRunning_WhenChildFaults() + { + // Arrange + var parallel = new Parallel( + new WriteLine("Before Fault"), + new Fault { Message = new("Test fault") }, + new WriteLine("After Fault") + ); + + // Act + var result = await _serviceProvider.RunActivityAsync(parallel); + + // Assert + var journal = result.Journal; + var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel); + + Assert.NotNull(parallelContext); + Assert.Equal(ActivityStatus.Running, parallelContext.Status); + Assert.Equal(1, parallelContext.AggregateFaultCount); + // The non-faulted activities should still execute + Assert.Contains("Before Fault", _capturingTextWriter.Lines); + Assert.Contains("After Fault", _capturingTextWriter.Lines); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs new file mode 100644 index 000000000..413e2fbca --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs @@ -0,0 +1,21 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +/// +/// Shared test helpers for input evaluation tests. +/// +public static class EvaluationTestHelpers +{ + /// + /// Creates an ActivityExecutionContext for any activity. + /// + public static async Task CreateContextAsync(TActivity activity) + where TActivity : IActivity + { + var fixture = new ActivityTestFixture(activity); + return await fixture.BuildAsync(); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs new file mode 100644 index 000000000..f44678614 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs @@ -0,0 +1,85 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Exceptions; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputEvaluationErrorTests +{ + [Theory(DisplayName = "Wraps evaluation exceptions in InputEvaluationException")] + [InlineData(typeof(InvalidOperationException), "Expression evaluation failed")] + [InlineData(typeof(ArgumentException), "Detailed inner error message")] + [InlineData(typeof(TimeoutException), "Expression evaluation timed out")] + public async Task WrapsEvaluationExceptions(Type exceptionType, string errorMessage) + { + // Arrange + var exception = (Exception)Activator.CreateInstance(exceptionType, errorMessage)!; + var writeLine = new WriteLine("Test"); + var context = await CreateContextWithMockEvaluatorAsync(writeLine, exception); + + // Act & Assert + var wrappedException = await Assert.ThrowsAsync( + async () => await context.EvaluateInputPropertiesAsync()); + + Assert.Equal("Text", wrappedException.InputName); + Assert.Contains("Failed to evaluate activity input 'Text'", wrappedException.Message); + Assert.IsType(exceptionType, wrappedException.InnerException); + Assert.Equal(errorMessage, wrappedException.InnerException.Message); + Assert.NotNull(wrappedException.InnerException.StackTrace); + } + + [Fact(DisplayName = "Handles empty expression gracefully without throwing")] + public async Task HandlesEmptyExpressionGracefully() + { + // Arrange + var writeLine = new WriteLine(""); + var context = await CreateContextAsync(writeLine); + + // Act + var exception = await Record.ExceptionAsync( + async () => await context.EvaluateInputPropertiesAsync()); + + // Assert + Assert.Null(exception); + } + + private static async Task CreateContextWithMockEvaluatorAsync( + WriteLine writeLine, + Exception thrownException) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(thrownException); + + var mockDescriptor = new ExpressionDescriptor + { + Type = "Literal", + HandlerFactory = _ => mockHandler + }; + + var mockProvider = Substitute.For(); + mockProvider.GetDescriptors().Returns([mockDescriptor]); + + var fixture = new ActivityTestFixture(writeLine) + .ConfigureServices(services => + { + services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider)); + services.AddSingleton(mockProvider); + }); + + return await fixture.BuildAsync(); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs new file mode 100644 index 000000000..54109eccc --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationTests.cs @@ -0,0 +1,73 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputEvaluationTests +{ + [Theory(DisplayName = "Evaluates literal input values correctly")] + [InlineData("Literal Value")] + [InlineData("")] + [InlineData("Special chars: !@#$%")] + public async Task EvaluatesLiteralInput(string inputValue) + { + // Arrange + var writeLine = new WriteLine(inputValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(inputValue, context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Evaluates delegate input expressions")] + public async Task EvaluatesDelegateInput() + { + // Arrange + const string original = "Original"; + var writeLine = new WriteLine(new Input(() => $"TRANSFORMED_{original}")); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Equal("TRANSFORMED_Original", context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Handles null input gracefully")] + public async Task HandlesNullInput() + { + // Arrange + var writeLine = new WriteLine(new Input(default(string)!)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Null(context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Stores evaluated value in activity state")] + public async Task StoresEvaluatedValueInActivityState() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(expectedValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.NotNull(writeLine.Text); + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(expectedValue, context.ActivityState["Text"]); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs new file mode 100644 index 000000000..28cd1da89 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputPropertyEvaluationTests.cs @@ -0,0 +1,87 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputPropertyEvaluationTests +{ + [Fact(DisplayName = "Evaluates all auto-evaluate inputs")] + public async Task EvaluatesAllAutoEvaluateInputs() + { + // Arrange + const string expectedText = "Hello, World!"; + var writeLine = new WriteLine(expectedText); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedText, context.ActivityState.Values); + } + + [Fact(DisplayName = "Evaluates specific input by name")] + public async Task EvaluatesSpecificInputByName() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(expectedValue); + var context = await CreateContextAsync(writeLine); + + // Act + var result = await context.EvaluateInputPropertyAsync("Text"); + + // Assert + Assert.Equal(expectedValue, result); + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Fact(DisplayName = "Evaluates specific input by property expression")] + public async Task EvaluatesSpecificInputByExpression() + { + // Arrange + const int expectedValue = 42; + var variable = new Variable("myVar", expectedValue, "myVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + var result = await context.EvaluateInputPropertyAsync, int>(x => x.Value); + + // Assert + Assert.Equal(expectedValue, result); + } + + [Fact(DisplayName = "Throws when input name not found")] + public async Task ThrowsWhenInputNameNotFound() + { + // Arrange + var writeLine = new WriteLine("Test"); + var context = await CreateContextAsync(writeLine); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await context.EvaluateInputPropertyAsync("NonExistentInput")); + + Assert.Contains("No input with name NonExistentInput could be found", exception.Message); + } + + [Fact(DisplayName = "Sets HasEvaluatedProperties flag after evaluation")] + public async Task SetsHasEvaluatedPropertiesFlag() + { + // Arrange + var writeLine = new WriteLine("Test"); + var context = await CreateContextAsync(writeLine); + + Assert.False(context.GetHasEvaluatedProperties()); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.GetHasEvaluatedProperties()); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs new file mode 100644 index 000000000..67132cbc6 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputStateStorageTests.cs @@ -0,0 +1,152 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class InputStateStorageTests +{ + [Theory(DisplayName = "Stores primitive values in activity state")] + [InlineData(42)] + [InlineData(0)] + [InlineData(-100)] + public async Task StoresPrimitiveIntValues(int expectedValue) + { + // Arrange + var variable = new Variable("intVar", 0, "intVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Theory(DisplayName = "Stores boolean values in activity state")] + [InlineData(true)] + [InlineData(false)] + public async Task StoresBooleanValues(bool expectedValue) + { + // Arrange + var variable = new Variable("boolVar", false, "boolVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Theory(DisplayName = "Stores string values in activity state")] + [InlineData("Test String")] + [InlineData("")] + public async Task StoresStringValues(string expectedValue) + { + // Arrange + var variable = new Variable("stringVar", "", "stringVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Contains(expectedValue, context.ActivityState.Values); + } + + [Fact(DisplayName = "Stores values using input descriptor name as key")] + public async Task StoresValuesByInputDescriptorName() + { + // Arrange + const string textValue = "Named Input Test"; + var writeLine = new WriteLine(textValue); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Equal(textValue, context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Stores null values correctly")] + public async Task StoresNullValues() + { + // Arrange + var writeLine = new WriteLine(new Input(default(string)!)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.ContainsKey("Text")); + Assert.Null(context.ActivityState["Text"]); + } + + [Fact(DisplayName = "Overwrites previous values on re-evaluation")] + public async Task OverwritesPreviousValuesOnReEvaluation() + { + // Arrange + const string initialValue = "Initial"; + const string updatedValue = "Updated"; + var writeLine = new WriteLine(initialValue); + var context = await CreateContextAsync(writeLine); + + // Act - First evaluation + await context.EvaluateInputPropertiesAsync(); + var firstValue = context.ActivityState["Text"]; + + // Modify and re-evaluate + writeLine.Text = new(updatedValue); + await context.EvaluateInputPropertyAsync("Text"); + var secondValue = context.ActivityState["Text"]; + + // Assert + Assert.Equal(initialValue, firstValue); + Assert.Equal(updatedValue, secondValue); + } + + [Fact(DisplayName = "Stores multiple inputs separately")] + public async Task StoresMultipleInputsSeparately() + { + // Arrange + var variable = new Variable("multiVar", 100, "multiVar"); + var setVariable = new SetVariable(variable, new Input(200)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.True(context.ActivityState.Count >= 1); + } + + [Fact(DisplayName = "Maintains state across multiple evaluations")] + public async Task MaintainsStateAcrossMultipleEvaluations() + { + // Arrange + const string value1 = "First"; + var writeLine1 = new WriteLine(value1); + var context = await CreateContextAsync(writeLine1); + + // Act - First evaluation + await context.EvaluateInputPropertiesAsync(); + var stateCountAfterFirst = context.ActivityState.Count; + + // Second evaluation of same input + await context.EvaluateInputPropertyAsync("Text"); + var stateCountAfterSecond = context.ActivityState.Count; + + // Assert + Assert.Equal(stateCountAfterFirst, stateCountAfterSecond); + Assert.Equal(value1, context.ActivityState["Text"]); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs new file mode 100644 index 000000000..6cddc9807 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/WrappedInputEvaluationTests.cs @@ -0,0 +1,103 @@ +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers; + +namespace Elsa.Workflows.IntegrationTests.Evaluation; + +public class WrappedInputEvaluationTests +{ + [Fact(DisplayName = "Sets memory block reference with deterministic ID")] + public async Task SetsMemoryBlockReferenceWithDeterministicId() + { + // Arrange + const string expectedValue = "Test Value"; + var writeLine = new WriteLine(new Input(expectedValue)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = writeLine.Text.MemoryBlockReference(); + Assert.NotNull(memoryReference); + var storedValue = memoryReference.Get(context.ExpressionExecutionContext); + Assert.Equal(expectedValue, storedValue); + } + + [Fact(DisplayName = "Handles missing memory block reference ID")] + public async Task HandlesMissingMemoryBlockReferenceId() + { + // Arrange + const string expectedValue = "Memory Test"; + var writeLine = new WriteLine(new Input(expectedValue)); + var context = await CreateContextAsync(writeLine); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = writeLine.Text.MemoryBlockReference(); + Assert.NotNull(memoryReference); + Assert.NotNull(memoryReference.Id); + Assert.False(string.IsNullOrEmpty(memoryReference.Id)); + } + + [Fact(DisplayName = "Stores evaluated value in ExpressionExecutionContext")] + public async Task StoresEvaluatedValueInExpressionExecutionContext() + { + // Arrange + const int expectedValue = 123; + var variable = new Variable("testVar", 0, "testVar"); + var setVariable = new SetVariable(variable, new Input(expectedValue)); + var context = await CreateContextAsync(setVariable); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + var memoryReference = setVariable.Value.MemoryBlockReference(); + var storedValue = memoryReference.Get(context.ExpressionExecutionContext); + Assert.Equal(expectedValue, storedValue); + } + + [Theory(DisplayName = "Evaluates different expression types correctly")] + [InlineData("Literal", "Literal Test")] + [InlineData("Delegate", "Delegate Result")] + [InlineData("Variable", "Variable Value")] + public async Task EvaluatesExpressionTypes(string expressionType, string expectedValue) + { + // Arrange + WriteLine writeLine; + Variable? variable = null; + + switch (expressionType) + { + case "Literal": + var literal = new Literal(expectedValue); + writeLine = new(new Input(literal)); + break; + case "Delegate": + writeLine = new(new Input(() => expectedValue)); + break; + case "Variable": + variable = new("myVar", expectedValue, "myVar"); + writeLine = new(new Input(variable)); + break; + default: + throw new ArgumentException("Invalid expression type"); + } + + var context = await CreateContextAsync(writeLine); + + variable?.Set(context.ExpressionExecutionContext, expectedValue); + + // Act + await context.EvaluateInputPropertiesAsync(); + + // Assert + Assert.Equal(expectedValue, context.ActivityState["Text"]); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs b/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs index 92c644a9f..ba2e5fa0d 100644 --- a/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Console/WriteLineTests.cs @@ -1,7 +1,8 @@ -using Elsa.Activities.UnitTests.Helpers; +using Elsa.Testing.Shared; using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +// ReSharper disable MethodHasAsyncOverload namespace Elsa.Activities.UnitTests.Console; @@ -17,18 +18,16 @@ public class WriteLineTests _mockProvider.GetTextWriter().Returns(_mockTextWriter); } - [Fact] - public async Task Should_Write_String_Literal_To_Output() + [Theory] + [InlineData("Hello, World!")] + [InlineData("")] + public async Task Should_Write_Text_To_Output(string expectedText) { // Arrange - const string expectedText = "Hello, World!"; var writeLine = new WriteLine(expectedText); // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); + await ExecuteAsync(writeLine); // Assert _mockTextWriter.Received(1).WriteLine(expectedText); @@ -38,35 +37,15 @@ public class WriteLineTests public async Task Should_Write_Null_Value_To_Output() { // Arrange - var writeLine = new WriteLine(new Input((string?)null)); + var writeLine = new WriteLine(new Input(default(string)!)); // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); + await ExecuteAsync(writeLine); // Assert _mockTextWriter.Received(1).WriteLine(Arg.Is(s => s == null)); } - [Fact] - public async Task Should_Write_Empty_String_To_Output() - { - // Arrange - const string expectedText = ""; - var writeLine = new WriteLine(expectedText); - - // Act - await ActivityTestHelper.ExecuteActivityAsync(writeLine, services => - { - services.AddSingleton(_mockProvider); - }); - - // Assert - _mockTextWriter.Received(1).WriteLine(expectedText); - } - [Fact] public async Task Should_Use_Default_Provider_When_None_Configured() { @@ -75,9 +54,15 @@ public class WriteLineTests var writeLine = new WriteLine(textToWrite); // Act & Assert - Should not throw exception when no provider is configured - var exception = await Record.ExceptionAsync(async () => - await ActivityTestHelper.ExecuteActivityAsync(writeLine)); - + var exception = await Record.ExceptionAsync(() => ExecuteAsync(writeLine)); + Assert.Null(exception); } + + private async Task ExecuteAsync(IActivity activity) + { + await new ActivityTestFixture(activity) + .ConfigureServices(services => services.AddSingleton(_mockProvider)) + .ExecuteAsync(); + } } diff --git a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj index e2342d12e..5b8d3f4e6 100644 --- a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj +++ b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj @@ -1,6 +1,7 @@  + diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs new file mode 100644 index 000000000..ea6b8de4f --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Text; +using Elsa.Http; +using Elsa.Http.ContentWriters; +using Elsa.Http.Parsers; +using Elsa.Resilience; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Helpers; + +/// +/// Extension methods for configuring HTTP-related services in ActivityTestFixture. +/// +public static class ActivityTestFixtureHttpExtensions +{ + /// + /// Configures services commonly needed for HTTP activity testing. + /// This is a convenience method that combines HTTP services, mock HTTP client factory, and mock resilient invoker. + /// + /// The test fixture to configure + /// Handler for HTTP requests, or null to use a default OK response + /// The fixture instance for method chaining + public static ActivityTestFixture WithHttpServices( + this ActivityTestFixture fixture, + Func>? responseHandler = null) + { + fixture.ConfigureServices(services => + { + var defaultHandler = responseHandler ?? ((_, _) => Task.FromResult(CreateHttpResponse(HttpStatusCode.OK))); + var mockHttpClientFactory = CreateMockHttpClientFactory(defaultHandler); + + services.AddSingleton(mockHttpClientFactory); + services.AddSingleton(CreateMockResilientActivityInvoker()); + AddHttpServices(services); + services.AddLogging(); + }); + + return fixture; + } + + /// + /// Creates a simple HTTP response message with specified status code and optional JSON content. + /// + /// The HTTP status code for the response + /// Optional JSON content for the response body + /// Optional additional headers to add to the response + /// An HttpResponseMessage configured with the specified parameters + public static HttpResponseMessage CreateHttpResponse( + HttpStatusCode statusCode, + string? jsonContent = null, + Dictionary? additionalHeaders = null) + { + var response = new HttpResponseMessage(statusCode); + + if (jsonContent != null) + { + response.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + } + + if (additionalHeaders != null) + { + foreach (var header in additionalHeaders) + { + response.Headers.Add(header.Key, header.Value); + } + } + + return response; + } + + /// + /// Adds all HTTP-related services to the service collection. + /// This includes content factories, parsers, and HTTP client services. + /// + private static void AddHttpServices(IServiceCollection services) + { + // Add all required HTTP services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Add HTTP content parsers + AddHttpContentParsers(services); + + // Add other required services + services.AddHttpClient(); + } + + /// + /// Adds HTTP content parsers to the service collection. + /// These parsers are responsible for parsing different content types in HTTP responses. + /// + private static void AddHttpContentParsers(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Creates a mock IResilientActivityInvoker that directly executes the provided action. + /// Useful for testing activities that depend on resilient execution without the complexity of retry policies. + /// + private static IResilientActivityInvoker CreateMockResilientActivityInvoker() + { + var mock = Substitute.For(); + + // Configure the mock to simply execute the provided action directly + mock.InvokeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(callInfo => + { + var action = callInfo.ArgAt>>(2); + return action.Invoke(); + }); + + return mock; + } + + /// + /// Creates a mock HTTP client factory with a test handler for controlled HTTP responses. + /// + private static IHttpClientFactory CreateMockHttpClientFactory( + Func> responseHandler) + { + var mockHttpClientFactory = Substitute.For(); + var testHandler = new TestHttpMessageHandler(responseHandler); + var httpClient = new HttpClient(testHandler); + mockHttpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + return mockHttpClientFactory; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs deleted file mode 100644 index 7faf64bb5..000000000 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs +++ /dev/null @@ -1,186 +0,0 @@ -using Elsa.Common; -using Elsa.Expressions.Contracts; -using Elsa.Expressions.Services; -using Elsa.Extensions; -using Elsa.Http.ContentWriters; -using Elsa.Http.Parsers; -using Elsa.Mediator.Contracts; -using Elsa.Resilience; -using Elsa.Workflows; -using Elsa.Workflows.CommitStates; -using Elsa.Workflows.Management.Providers; -using Elsa.Workflows.Management.Services; -using Elsa.Workflows.PortResolvers; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using Elsa.Http; - -namespace Elsa.Activities.UnitTests.Helpers; - -/// -/// Helper class for unit testing activities in isolation. -/// Provides methods to execute activities with minimal setup, abstracting away the complexity -/// of creating WorkflowExecutionContext and ActivityExecutionContext. -/// -public static class ActivityTestHelper -{ - /// - /// Executes an activity in isolation for unit testing purposes and returns the execution context. - /// This method handles all the complexity of setting up the execution context, - /// evaluating inputs, and executing the activity. - /// - /// The activity to execute - /// An optional action to configure the service collection for dependency injection during activity execution. - /// The ActivityExecutionContext used for execution - public static async Task ExecuteActivityAsync(IActivity activity, Action? configureServices = null) - { - var context = await CreateMinimalActivityExecutionContext(activity, configureServices); - - // Set up variables and inputs, then execute the activity - await SetupExistingVariablesAsync(activity, context); - await context.EvaluateInputPropertiesAsync(); - await activity.ExecuteAsync(context); - - return context; - } - - /// - /// Adds all HTTP-related services to the service collection. - /// This includes content factories, parsers, and HTTP client services. - /// Use this method when testing HTTP activities that require full HTTP service support. - /// - /// The service collection to add HTTP services to - public static void AddHttpServices(IServiceCollection services) - { - // Add all required HTTP services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Add HTTP content parsers - AddHttpContentParsers(services); - - // Add other required services - services.AddHttpClient(); - } - - /// - /// Creates a mock IResilientActivityInvoker that directly executes the provided action. - /// Useful for testing activities that depend on resilient execution without the complexity of retry policies. - /// - /// A mock IResilientActivityInvoker configured to execute actions directly - public static IResilientActivityInvoker CreateMockResilientActivityInvoker() - { - var mock = Substitute.For(); - - // Configure the mock to simply execute the provided action directly - mock.InvokeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns(callInfo => - { - var action = callInfo.ArgAt>>(2); - return action.Invoke(); - }); - - return mock; - } - - /// - /// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities. - /// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern - /// to provide proper context for activities. - /// - private static async Task CreateMinimalActivityExecutionContext(IActivity activity, Action? configureServices) - { - // Create a minimal service provider with the required services for expression evaluation - var services = new ServiceCollection(); - - // Add core services - services.AddLogging(); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Add the default expression descriptor provider which includes Literal expressions - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(_ => Substitute.For()); - - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - services.AddSingleton(_ => Substitute.For()); - - // Call the configure services action if provided. - configureServices?.Invoke(services); - - var serviceProvider = services.BuildServiceProvider(); - var activityRegistry = serviceProvider.GetRequiredService(); - var workflowGraphBuilder = serviceProvider.GetRequiredService(); - await activityRegistry.RegisterAsync(activity.GetType()); - var workflow = Workflow.FromActivity(activity); - var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow); - - // Create workflow execution context using the static factory method - var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync( - serviceProvider, - workflowGraph, - $"test-instance-{Guid.NewGuid()}", - CancellationToken.None - ); - - // Create ActivityExecutionContext for the actual activity we want to test - return await workflowExecutionContext.CreateActivityExecutionContextAsync(activity); - } - - /// - /// Sets up existing variables found on the activity in the execution context. - /// This is necessary because in unit tests, variables need to be initialized. - /// - private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context) - { - var activityType = activity.GetType(); - var variableProperties = activityType.GetProperties() - .Where(p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>)) - .ToList(); - - foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!)) - { - variable.Set(context.ExpressionExecutionContext, variable.Value); - } - - return Task.CompletedTask; - } - - /// - /// Adds HTTP content parsers to the service collection. - /// These parsers are responsible for parsing different content types in HTTP responses. - /// - /// The service collection to add parsers to - private static void AddHttpContentParsers(IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - } -} diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs b/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs index 88b163157..0b9f1843d 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs +++ b/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs @@ -4,24 +4,14 @@ namespace Elsa.Activities.UnitTests.Helpers; /// Custom test HTTP message handler that allows full control over HTTP responses for testing. /// Use this class to simulate different HTTP scenarios without making actual network calls. /// -public class TestHttpMessageHandler : HttpMessageHandler +/// Function that defines how to handle HTTP requests and generate responses +public class TestHttpMessageHandler(Func> sendAsyncFunc) : HttpMessageHandler { - private readonly Func> _sendAsyncFunc; - - /// - /// Initializes a new instance of TestHttpMessageHandler. - /// - /// Function that defines how to handle HTTP requests and generate responses - public TestHttpMessageHandler(Func> sendAsyncFunc) - { - _sendAsyncFunc = sendAsyncFunc; - } - /// /// Handles HTTP requests using the provided function. /// protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return _sendAsyncFunc(request, cancellationToken); + return sendAsyncFunc(request, cancellationToken); } } diff --git a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs new file mode 100644 index 000000000..71a32a7bb --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs @@ -0,0 +1,288 @@ +using System.Net; +using Elsa.Activities.UnitTests.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Http; + +public class SendHttpRequestTests +{ + [Theory] + [InlineData("GET", "https://api.example.com/data", "{\"result\": \"success\"}", 200)] + [InlineData("POST", "https://api.example.com/create", "{\"id\": 123}", 201)] + [InlineData("PUT", "https://api.example.com/update", "{\"updated\": true}", 200)] + public async Task Should_Send_Request_And_Handle_Success_Response(string method, string url, string jsonResponse, int expectedStatusCode) + { + // Arrange + var expectedUrl = new Uri(url); + var expectedMethod = new HttpMethod(method); + var expectedHttpStatusCode = (HttpStatusCode)expectedStatusCode; + var requestCapture = new RequestCapture(); + var responseHandler = CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture); + var sendHttpRequest = CreateSendHttpRequest(expectedUrl, method); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method); + Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri); + + var statusCodeOutput = context.GetActivityOutput(() => sendHttpRequest.StatusCode); + Assert.Equal(expectedStatusCode, statusCodeOutput); + } + + [Theory] + [InlineData("Bearer token123")] + [InlineData("Basic YWRtaW46cGFzcw==")] + [InlineData("ApiKey abc123")] + public async Task Should_Add_Authorization_Header(string authorizationHeader) + { + // Arrange + var expectedUrl = new Uri("https://api.example.com/secure"); + + var requestCapture = new RequestCapture(); + var responseHandler = CreateResponseHandler(HttpStatusCode.OK, null, requestCapture); + var sendHttpRequest = CreateSendHttpRequest(expectedUrl, authorization: authorizationHeader); + + // Act + await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization); + Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString()); + } + + [Theory] + [InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.NotFound, "mockActivity404")] + [InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.InternalServerError, "mockUnmatchedActivity")] + public async Task Should_Schedule_Activity_According_To_Handlers(int[] statusCodes, string[] activityNames, string handler, HttpStatusCode expectedStatusCode, string expectedScheduledActivityName) + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithStatusHandlers( + expectedStatusCodes: [(statusCodes[0], activityNames[0]), (statusCodes[1], activityNames[1])], + unmatchedHandler: handler + ); + + var responseHandler = CreateResponseHandler(expectedStatusCode); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert that the correct activity was scheduled. + var expectedActivity = childActivities[expectedScheduledActivityName]; + var hasScheduledActivity = context.HasScheduledActivity(expectedActivity); + Assert.True(hasScheduledActivity); + } + + [Fact] + public async Task Should_Schedule_FailedToConnect_Activity_On_HttpRequestException() + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers( + failedToConnect: "mockFailedToConnect" + ); + + var responseHandler = CreateExceptionHandler("Connection failed"); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var expectedScheduledActivity = childActivities["mockFailedToConnect"]; + var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity); + Assert.True(hasScheduledExpectedActivity); + } + + [Fact] + public async Task Should_Schedule_Timeout_Activity_On_TaskCanceledException() + { + // Arrange + var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers( + timeout: "mockTimeout" + ); + + var responseHandler = CreateExceptionHandler("Request timed out"); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var expectedScheduledActivity = childActivities["mockTimeout"]; + var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity); + Assert.True(hasScheduledExpectedActivity); + } + + [Fact] + public async Task Should_Schedule_No_Activity_When_No_Status_Code_Cases_Match_And_No_Unmatched_Handler() + { + // Arrange + var (configured, _) = CreateSendHttpRequestWithStatusHandlers([(200, "handler200")], unmatchedHandler: null); + var responseHandler = CreateResponseHandler(HttpStatusCode.InternalServerError); // 500 - no match + + // Act + var context = await ExecuteActivityAsync(configured, responseHandler); + + // Assert + var allScheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(allScheduledActivities); + } + + [Fact] + public async Task Should_Set_Response_Headers_Output() + { + // Arrange + var expectedHeaders = new Dictionary + { + { "Custom-Header", "CustomValue" }, + { "X-Rate-Limit", "100" } + }; + + var responseHandler = CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders); + var sendHttpRequest = CreateSendHttpRequest(new("https://api.example.com/headers")); + + // Act + var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler); + + // Assert + var responseHeadersObj = context.GetActivityOutput(() => sendHttpRequest.ResponseHeaders); + var responseHeaders = responseHeadersObj as HttpHeaders; + Assert.NotNull(responseHeaders); + Assert.True(responseHeaders.ContainsKey("Custom-Header")); + Assert.True(responseHeaders.ContainsKey("X-Rate-Limit")); + } + + [Fact] + public void Should_Have_Correct_Activity_Attributes() + { + var fixture = new ActivityTestFixture(new SendHttpRequest()); + fixture.AssertActivityAttributes( + expectedNamespace: "Elsa", + expectedCategory: "HTTP", + expectedDisplayName: "HTTP Request", + expectedDescription: "Send an HTTP request.", + expectedKind: ActivityKind.Task + ); + } + + // Private helper methods placed after all public members + private static Func> CreateResponseHandler( + HttpStatusCode statusCode, + string? content = null, + RequestCapture? requestCapture = null, + Dictionary? additionalHeaders = null) + { + return (request, _) => + { + if (requestCapture != null) + requestCapture.CapturedRequest = request; + return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders)); + }; + } + + private sealed class RequestCapture + { + public HttpRequestMessage? CapturedRequest { get; set; } + } + + private static SendHttpRequest CreateSendHttpRequest( + Uri url, + string method = "GET", + object? content = null, + string? contentType = null, + string? authorization = null) + { + return new() + { + Url = new(url), + Method = new(method), + Content = content != null ? new Input(content) : null!, + ContentType = contentType != null ? new Input(contentType) : null!, + Authorization = authorization != null ? new Input(authorization) : null!, + ExpectedStatusCodes = new List() + }; + } + + private static Task ExecuteActivityAsync( + SendHttpRequest sendHttpRequest, + Func> responseHandler) + { + return new ActivityTestFixture(sendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync(); + } + + private static (SendHttpRequest sendHttpRequest, Dictionary childActivities) CreateSendHttpRequestWithStatusHandlers( + (int statusCode, string activityName)[] expectedStatusCodes, + string? unmatchedHandler) + { + var childActivities = new Dictionary(); + + // Create mock activities for expected status codes + var expectedStatusCodeCases = expectedStatusCodes.Select(x => + { + var mockActivity = Substitute.For(); + childActivities[x.activityName] = mockActivity; + return new HttpStatusCodeCase(x.statusCode, mockActivity); + }).ToList(); + + // Create mock activity for unmatched handler + var unmatchedActivity = Substitute.For(); + if (unmatchedHandler is not null) + { + childActivities[unmatchedHandler] = unmatchedActivity; + } + + var sendHttpRequest = new SendHttpRequest + { + Url = new(new Uri("https://api.example.com/test")), + Method = new("GET"), + ExpectedStatusCodes = expectedStatusCodeCases, + UnmatchedStatusCode = unmatchedHandler is not null ? unmatchedActivity : null + }; + + return (sendHttpRequest, childActivities); + } + + private static (SendHttpRequest sendHttpRequest, Dictionary childActivities) CreateSendHttpRequestWithErrorHandlers( + string? failedToConnect = null, + string? timeout = null) + { + var childActivities = new Dictionary(); + + IActivity? failedToConnectActivity = null; + IActivity? timeoutActivity = null; + + if (failedToConnect != null) + { + failedToConnectActivity = Substitute.For(); + childActivities[failedToConnect] = failedToConnectActivity; + } + + if (timeout != null) + { + timeoutActivity = Substitute.For(); + childActivities[timeout] = timeoutActivity; + } + + var sendHttpRequest = new SendHttpRequest + { + Url = new(new Uri("https://api.example.com/error")), + Method = new("GET"), + ExpectedStatusCodes = new List(), + FailedToConnect = failedToConnectActivity, + Timeout = timeoutActivity + }; + + return (sendHttpRequest, childActivities); + } + + private static Func> CreateExceptionHandler(string message) + where TException : Exception + { + return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs b/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs new file mode 100644 index 000000000..1e0d9d435 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Parallel/ParallelTests.cs @@ -0,0 +1,97 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Parallel; + +public class ParallelTests +{ + [Theory(DisplayName = "Parallel should schedule multiple activities")] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task Should_Schedule_Multiple_Activities(int activityCount) + { + // Arrange + var activities = Enumerable.Range(1, activityCount) + .Select(i => new WriteLine($"Activity {i}")) + .Cast() + .ToArray(); + + var parallel = new Workflows.Activities.Parallel(activities); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - All activities should be scheduled + foreach (var activity in activities) + { + Assert.True(context.HasScheduledActivity(activity)); + } + } + + [Fact(DisplayName = "Parallel should not schedule activities when empty")] + public async Task Should_Not_Schedule_Activities_When_Empty() + { + // Arrange + var parallel = new Workflows.Activities.Parallel(); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - No activities should be scheduled (completion is tested in integration tests) + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(scheduledActivities); + } + + [Fact(DisplayName = "Parallel should schedule a single activity")] + public async Task Should_Execute_Single_Activity() + { + // Arrange + var activity = Substitute.For(); + var parallel = new Workflows.Activities.Parallel(activity); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } + + [Fact(DisplayName = "Parallel should schedule mixed activity types")] + public async Task Should_Schedule_Mixed_Activity_Types() + { + // Arrange + var writeLine = new WriteLine("Test output"); + var testVar = new Variable("testVar", 0, "testVar"); + var setVariable = new SetVariable(testVar, new Input(42)); + var mockActivity = Substitute.For(); + + var parallel = new Workflows.Activities.Parallel(writeLine, setVariable, mockActivity); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - All activities should be scheduled + Assert.True(context.HasScheduledActivity(writeLine)); + Assert.True(context.HasScheduledActivity(setVariable)); + Assert.True(context.HasScheduledActivity(mockActivity)); + } + + [Fact(DisplayName = "Parallel should track scheduled children count internally")] + public async Task Should_Set_Scheduled_Children_Property() + { + // Arrange + var activity1 = new WriteLine("Activity 1"); + var activity2 = new WriteLine("Activity 2"); + var activity3 = new WriteLine("Activity 3"); + var parallel = new Workflows.Activities.Parallel(activity1, activity2, activity3); + + // Act + var context = await new ActivityTestFixture(parallel).ExecuteAsync(); + + // Assert - The parallel activity should track that 3 children were scheduled + var scheduledChildrenCount = context.GetProperty("ScheduledChildren"); + Assert.Equal(3, scheduledChildrenCount); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs index 2f5c7ec5d..361848f8e 100644 --- a/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetVariableTests.cs @@ -1,4 +1,5 @@ -using Elsa.Activities.UnitTests.Helpers; +using Elsa.Testing.Shared; +using Elsa.Workflows; namespace Elsa.Activities.UnitTests.Primitives; @@ -13,7 +14,7 @@ public class SetVariableTests var setVariable = new SetVariable(variable, new Input(expected)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var context = await ExecuteAsync(setVariable); // Assert var result = variable.Get(context); @@ -27,7 +28,7 @@ public class SetVariableTests var setVariable = new SetVariable(null!, new Input("test value")); // Act & Assert - var exception = await Record.ExceptionAsync(async () => await ActivityTestHelper.ExecuteActivityAsync(setVariable)); + var exception = await Record.ExceptionAsync(() => ExecuteAsync(setVariable)); Assert.NotNull(exception); } @@ -40,10 +41,15 @@ public class SetVariableTests var setVariable = new SetVariable(variable, new Input((string?)null)); // Act - var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable); + var context = await ExecuteAsync(setVariable); // Assert var result = variable.Get(context); Assert.Null(result); } + + private static Task ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } } \ No newline at end of file diff --git a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj new file mode 100644 index 000000000..1d098435a --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs b/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs new file mode 100644 index 000000000..3b5b6194a --- /dev/null +++ b/test/unit/Elsa.Expressions.UnitTests/Services/ExpressionEvaluatorTests.cs @@ -0,0 +1,169 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Elsa.Expressions.UnitTests.Services; + +public class ExpressionEvaluatorTests +{ + [Fact(DisplayName = "Evaluates expression with generic type parameter")] + public async Task EvaluatesExpressionWithGenericTypeParameter() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("Literal", "Test Value"); + + // Act + var result = await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + Assert.Equal("Test Value", result); + } + + [Fact(DisplayName = "Evaluates expression with type parameter")] + public async Task EvaluatesExpressionWithTypeParameter() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("Literal", 42); + + // Act + var result = await evaluator.EvaluateAsync(expression, typeof(int), context.ExpressionExecutionContext); + + // Assert + Assert.Equal(42, result); + } + + [Fact(DisplayName = "Throws when expression type not found in registry")] + public async Task ThrowsWhenExpressionTypeNotFound() + { + // Arrange + var context = await CreateContextAsync(); + var evaluator = context.GetRequiredService(); + var expression = new Expression("NonExistentType", "value"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext)); + + Assert.Contains("Could not find a descriptor for expression type", exception.Message); + Assert.Contains("NonExistentType", exception.Message); + } + + [Fact(DisplayName = "Resolves expression handler via descriptor factory")] + public async Task ResolvesExpressionHandlerViaDescriptorFactory() + { + // Arrange + var mockHandler = CreateMockHandler("Mocked Result"); + var context = await CreateContextWithMockHandlerAsync("CustomType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("CustomType", "test"); + + // Act + var result = await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + Assert.Equal("Mocked Result", result); + await mockHandler.Received(1).EvaluateAsync( + Arg.Is(e => e.Type == "CustomType" && e.Value as string == "test"), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact(DisplayName = "Passes non-null options as default when not provided")] + public async Task PassesNonNullOptionsAsDefault() + { + // Arrange + var mockHandler = CreateMockHandler("Result"); + var context = await CreateContextWithMockHandlerAsync("TestType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("TestType", "value"); + + // Act + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext); + + // Assert + await mockHandler.Received(1).EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(o => o != null)); + } + + [Fact(DisplayName = "Wraps handler exceptions in evaluation context")] + public async Task WrapsHandlerExceptions() + { + // Arrange + var mockHandler = CreateMockHandlerThatThrows(new InvalidOperationException("Handler failed")); + var context = await CreateContextWithMockHandlerAsync("FailingType", mockHandler); + var evaluator = context.GetRequiredService(); + var expression = new Expression("FailingType", "bad value"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await evaluator.EvaluateAsync(expression, context.ExpressionExecutionContext)); + + Assert.Equal("Handler failed", exception.Message); + } + + private static Task CreateContextAsync() + { + var activity = new WriteLine("Hello, World!"); + return new ActivityTestFixture(activity).BuildAsync(); + } + + private static async Task CreateContextWithMockHandlerAsync(string expressionType, IExpressionHandler mockHandler) + { + var mockDescriptor = new ExpressionDescriptor + { + Type = expressionType, + HandlerFactory = _ => mockHandler + }; + + var mockProvider = Substitute.For(); + mockProvider.GetDescriptors().Returns([mockDescriptor]); + + var activity = new WriteLine("Hello, World!"); + var fixture = new ActivityTestFixture(activity) + .ConfigureServices(services => + { + services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider)); + services.AddSingleton(mockProvider); + }); + + return await fixture.BuildAsync(); + } + + private static IExpressionHandler CreateMockHandler(object returnValue) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(returnValue); + return mockHandler; + } + + private static IExpressionHandler CreateMockHandlerThatThrows(Exception exception) + { + var mockHandler = Substitute.For(); + mockHandler.EvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(exception); + return mockHandler; + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj b/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj index 37c246d69..af5003d06 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj @@ -1,6 +1,7 @@ + diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs new file mode 100644 index 000000000..8db759f1e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/FaultHandlingTests.cs @@ -0,0 +1,39 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class FaultHandlingTests +{ + [Fact] + public async Task Fault_SetsExceptionAndStatus() + { + // Arrange + var context = await CreateContextAsync(); + var exception = new InvalidOperationException("Test error"); + + // Act + context.Fault(exception); + + // Assert + Assert.Equal(ActivityStatus.Faulted, context.Status); + Assert.Equal(exception, context.Exception); + Assert.Equal(1, context.AggregateFaultCount); + } + + [Fact] + public async Task RecoverFromFault_ResetsFaultCount() + { + // Arrange + var context = await CreateContextAsync(); + var exception = new InvalidOperationException("Test error"); + context.Fault(exception); + + // Act + context.RecoverFromFault(); + + // Assert + Assert.Equal(0, context.AggregateFaultCount); + Assert.Equal(ActivityStatus.Running, context.Status); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs new file mode 100644 index 000000000..fd9b8f60e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/HierarchyNavigationTests.cs @@ -0,0 +1,41 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class HierarchyNavigationTests +{ + [Theory] + [InlineData("GetAncestors")] + [InlineData("GetDescendants")] + public async Task HierarchyNavigation_ReturnsEmpty_WhenNoRelatives(string methodName) + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var result = methodName switch + { + "GetAncestors" => context.GetAncestors().ToList(), + "GetDescendants" => context.GetDescendants().ToList(), + _ => throw new ArgumentException($"Unknown method: {methodName}") + }; + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task FindParent_ReturnsNull_WhenNoMatch() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var parent = context.FindParent(x => x.Activity is Sequence); + + // Assert + Assert.Null(parent); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs new file mode 100644 index 000000000..a2fd375c4 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/MetadataTests.cs @@ -0,0 +1,50 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class MetadataTests +{ + [Fact] + public async Task SetExtensionsMetadata_StoresValue() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + context.SetExtensionsMetadata("testKey", "testValue"); + + // Assert + var metadata = context.GetExtensionsMetadata(); + Assert.NotNull(metadata); + Assert.Equal("testValue", metadata["testKey"]); + } + + [Fact] + public async Task GetExtensionsMetadata_ReturnsNull_WhenNotSet() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var metadata = context.GetExtensionsMetadata(); + + // Assert + Assert.Null(metadata); + } + + [Fact] + public async Task SetExtensionsMetadata_UpdatesExistingValue() + { + // Arrange + var context = await CreateContextAsync(); + context.SetExtensionsMetadata("testKey", "value1"); + + // Act + context.SetExtensionsMetadata("testKey", "value2"); + + // Assert + var metadata = context.GetExtensionsMetadata(); + Assert.Equal("value2", metadata!["testKey"]); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs new file mode 100644 index 000000000..dd808bab2 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/OutcomeTests.cs @@ -0,0 +1,35 @@ +using Elsa.Extensions; +using Elsa.Workflows.Attributes; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class OutcomeTests +{ + [Theory] + [InlineData(typeof(TestActivityWithPort), "Done")] + [InlineData(typeof(TestActivityWithNamedPort), "CustomOutcome")] + public async Task GetOutcomeName_ReturnsExpectedName(Type activityType, string expectedOutcomeName) + { + // Arrange + var activity = (Activity)Activator.CreateInstance(activityType)!; + var context = await CreateContextAsync(activity); + + // Act + var outcomeName = context.GetOutcomeName("Done"); + + // Assert + Assert.Equal(expectedOutcomeName, outcomeName); + } + + private class TestActivityWithPort : Activity + { + public IActivity? Done { get; set; } + } + + private class TestActivityWithNamedPort : Activity + { + [Port("CustomOutcome")] + public IActivity? Done { get; set; } + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs new file mode 100644 index 000000000..e9326b0c6 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/PropertyEvaluationTests.cs @@ -0,0 +1,24 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class PropertyEvaluationTests +{ + [Theory] + [InlineData(false)] // Initial state + [InlineData(true)] // After setting flag + public async Task HasEvaluatedProperties_TracksState(bool shouldSetFlag) + { + // Arrange + var context = await CreateContextAsync(); + if (shouldSetFlag) + context.SetHasEvaluatedProperties(); + + // Act + var result = context.GetHasEvaluatedProperties(); + + // Assert + Assert.Equal(shouldSetFlag, result); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs new file mode 100644 index 000000000..f688c2fea --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/ResultTests.cs @@ -0,0 +1,39 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class ResultTests +{ + [Fact] + public async Task SetResult_SetsResultProperty_WhenActivityImplementsIActivityWithResult() + { + // Arrange + var activity = new ActivityWithResult(); + var context = await CreateContextAsync(activity); + var testValue = "ResultValue"; + + // Act + context.SetResult(testValue); + + // Assert + var result = context.Get(activity.Result); + Assert.Equal(testValue, result); + } + + [Fact] + public async Task SetResult_ThrowsException_WhenActivityDoesNotImplementIActivityWithResult() + { + // Arrange + var context = await CreateContextAsync(); + + // Act & Assert + Assert.Throws(() => context.SetResult("value")); + } + + private class ActivityWithResult : Activity, IActivityWithResult + { + public Output? Result { get; set; } = new Output(); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs new file mode 100644 index 000000000..81d04a34e --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TestHelpers.cs @@ -0,0 +1,22 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +/// +/// Provides helper methods for ActivityExecutionContextExtensions unit tests. +/// +public static class TestHelpers +{ + /// + /// Creates an ActivityExecutionContext for testing purposes. + /// + /// Optional activity to use. If null, a default WriteLine activity is created. + /// A configured ActivityExecutionContext ready for testing. + public static Task CreateContextAsync(IActivity? activity = null) + { + activity ??= new WriteLine("test"); + var fixture = new ActivityTestFixture(activity); + return fixture.BuildAsync(); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs new file mode 100644 index 000000000..fa6c2b9a9 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/TriggerTests.cs @@ -0,0 +1,23 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class TriggerTests +{ + [Theory] + [InlineData(true, null)] // null means use context.Activity.Id + [InlineData(false, "different-id")] + public async Task IsTriggerOfWorkflow_ReturnsExpectedResult(bool expectedResult, string? triggerActivityId) + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.TriggerActivityId = triggerActivityId ?? context.Activity.Id; + + // Act + var result = context.IsTriggerOfWorkflow(); + + // Assert + Assert.Equal(expectedResult, result); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs new file mode 100644 index 000000000..2e2c970bb --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/VariableTests.cs @@ -0,0 +1,68 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class VariableTests +{ + [Fact] + public async Task CreateVariable_CreatesNewVariable_WithSpecifiedValue() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var variable = context.CreateVariable("testVar", 42); + + // Assert + Assert.NotNull(variable); + Assert.Equal("testVar", variable.Name); + var value = context.GetVariable("testVar"); + Assert.Equal(42, value); + } + + [Fact] + public async Task SetVariable_CreatesOrUpdatesVariable() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + context.SetVariable("testVar", 10); + context.SetVariable("testVar", 20); + + // Assert + var value = context.GetVariable("testVar"); + Assert.Equal(20, value); + } + + [Fact] + public async Task GetVariable_ReturnsNull_WhenVariableDoesNotExist() + { + // Arrange + var context = await CreateContextAsync(); + + // Act + var value = context.GetVariable("nonExistent"); + + // Assert + Assert.Null(value); + } + + [Fact] + public async Task GetVariableValues_ReturnsAllVariables_AcrossScopes() + { + // Arrange + var context = await CreateContextAsync(); + context.SetVariable("var1", "value1"); + context.SetVariable("var2", 42); + + // Act + var values = context.GetVariableValues(); + + // Assert + Assert.NotEmpty(values); + Assert.True(values.ContainsKey("var1Variable") || values.ContainsKey("var1")); + Assert.True(values.ContainsKey("var2Variable") || values.ContainsKey("var2")); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs new file mode 100644 index 000000000..f240561e8 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Extensions/ActivityExecutionContextExtensions/WorkflowInputTests.cs @@ -0,0 +1,72 @@ +using Elsa.Extensions; +using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers; + +namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions; + +public class WorkflowInputTests +{ + [Theory] + [InlineData("TestKey", "TestValue", true, "TestValue", null)] // Key exists, string value + [InlineData("NonExistentKey", null, false, null, null)] // Key doesn't exist + [InlineData("NumberKey", null, true, "42", 42)] // Type conversion from int to string + public async Task TryGetWorkflowInput_HandlesVariousScenarios( + string key, + string? stringValue, + bool expectedResult, + string? expectedValue, + object? inputValue) + { + // Arrange + var context = await CreateContextAsync(); + + if (stringValue != null) + context.WorkflowExecutionContext.Input[key] = stringValue; + else if (inputValue != null) + context.WorkflowExecutionContext.Input[key] = inputValue; + + // Act + var result = context.TryGetWorkflowInput(key, out var value); + + // Assert + Assert.Equal(expectedResult, result); + Assert.Equal(expectedValue, value); + } + + [Fact] + public async Task GetWorkflowInput_ReturnsValue_WhenKeyExists() + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.Input["TestKey"] = "TestValue"; + + // Act + var value = context.GetWorkflowInput("TestKey"); + + // Assert + Assert.Equal("TestValue", value); + } + + [Fact] + public async Task GetWorkflowInput_UsesTypeName_WhenKeyNotProvided() + { + // Arrange + var context = await CreateContextAsync(); + context.WorkflowExecutionContext.Input["String"] = "TestValue"; + + // Act + var value = context.GetWorkflowInput(); + + // Assert + Assert.Equal("TestValue", value); + } + + [Fact] + public async Task GetWorkflowInput_ThrowsException_WhenKeyDoesNotExist() + { + // Arrange + var context = await CreateContextAsync(); + + // Act & Assert + Assert.Throws(() => context.GetWorkflowInput("NonExistentKey")); + } +} diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj b/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj new file mode 100644 index 000000000..ac0c7cfde --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs new file mode 100644 index 000000000..ca4e5ecbd --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Services/ExpressionDescriptorRegistryTests.cs @@ -0,0 +1,225 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Management.Services; +using NSubstitute; + +namespace Elsa.Workflows.Management.UnitTests.Services; + +public class ExpressionDescriptorRegistryTests +{ + [Fact] + public void Should_Add_Descriptors_Via_Providers() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var provider1 = CreateProvider(descriptor1); + var provider2 = CreateProvider(descriptor2); + + // Act + var registry = CreateRegistry(provider1, provider2); + + // Assert + var allDescriptors = registry.ListAll().ToList(); + Assert.Contains(allDescriptors, d => d.Type == "Type1"); + Assert.Contains(allDescriptors, d => d.Type == "Type2"); + Assert.Equal(2, allDescriptors.Count); + } + + [Fact] + public void Should_Find_Descriptor_By_Type() + { + // Arrange + var descriptor = CreateDescriptor("TestType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find("TestType"); + + // Assert + Assert.NotNull(found); + Assert.Equal("TestType", found.Type); + } + + [Fact] + public void Should_Find_Descriptor_By_Predicate() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2)); + + // Act + var found = registry.Find(d => d.Type == "Type2"); + + // Assert + Assert.NotNull(found); + Assert.Equal("Type2", found.Type); + } + + [Theory] + [InlineData("NonExistentType")] + [InlineData("")] + [InlineData("WrongType")] + public void Should_Return_Null_When_Descriptor_Not_Found_By_Type(string searchType) + { + // Arrange + var descriptor = CreateDescriptor("ExistingType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find(searchType); + + // Assert + Assert.Null(found); + } + + [Fact] + public void Should_Return_Null_When_Descriptor_Not_Found_By_Predicate() + { + // Arrange + var descriptor = CreateDescriptor("Type1"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var found = registry.Find(d => d.Type == "NonExistent"); + + // Assert + Assert.Null(found); + } + + [Fact] + public void Should_List_All_Registered_Descriptors() + { + // Arrange + var descriptor1 = CreateDescriptor("Type1"); + var descriptor2 = CreateDescriptor("Type2"); + var descriptor3 = CreateDescriptor("Type3"); + var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2, descriptor3)); + + // Act + var all = registry.ListAll().ToList(); + + // Assert + Assert.Equal(3, all.Count); + Assert.Contains(all, d => d.Type == "Type1"); + Assert.Contains(all, d => d.Type == "Type2"); + Assert.Contains(all, d => d.Type == "Type3"); + } + + [Fact] + public void Should_Handle_Duplicate_Registrations_Last_Wins() + { + // Arrange - Two providers register the same type + var handler1 = Substitute.For(); + var handler2 = Substitute.For(); + var descriptor1 = CreateDescriptor("DuplicateType", handler1); + var descriptor2 = CreateDescriptor("DuplicateType", handler2); + var provider1 = CreateProvider(descriptor1); + var provider2 = CreateProvider(descriptor2); + + // Act - provider2 is registered after provider1 + var registry = CreateRegistry(provider1, provider2); + + // Assert - Last registration should win + var found = registry.Find("DuplicateType"); + Assert.NotNull(found); + Assert.Same(handler2, found.HandlerFactory(null!)); + } + + [Fact] + public void Should_Add_Single_Descriptor_Via_Add_Method() + { + // Arrange + var registry = CreateRegistry(CreateProvider()); + var newDescriptor = CreateDescriptor("NewType"); + + // Act + registry.Add(newDescriptor); + + // Assert + var found = registry.Find("NewType"); + Assert.NotNull(found); + Assert.Equal("NewType", found.Type); + } + + [Fact] + public void Should_Add_Multiple_Descriptors_Via_AddRange_Method() + { + // Arrange + var registry = CreateRegistry(CreateProvider()); + var descriptors = new[] + { + CreateDescriptor("Range1"), + CreateDescriptor("Range2"), + CreateDescriptor("Range3") + }; + + // Act + registry.AddRange(descriptors); + + // Assert + Assert.NotNull(registry.Find("Range1")); + Assert.NotNull(registry.Find("Range2")); + Assert.NotNull(registry.Find("Range3")); + } + + [Theory] + [InlineData(true)] // Empty provider list + [InlineData(false)] // Provider with empty descriptors + public void Should_Handle_Empty_Scenarios(bool emptyProviderList) + { + // Arrange & Act + var registry = emptyProviderList + ? CreateRegistry() + : CreateRegistry(CreateProvider()); + + // Assert + var all = registry.ListAll().ToList(); + Assert.Empty(all); + } + + [Fact] + public void Should_Enumerate_Descriptors_Multiple_Times() + { + // Arrange + var descriptor = CreateDescriptor("TestType"); + var registry = CreateRegistry(CreateProvider(descriptor)); + + // Act + var first = registry.ListAll().ToList(); + var second = registry.ListAll().ToList(); + + // Assert + Assert.Equal(first.Count, second.Count); + Assert.Single(first); + Assert.Single(second); + } + + private static ExpressionDescriptor CreateDescriptor(string type) + { + var handler = Substitute.For(); + return CreateDescriptor(type, handler); + } + + private static ExpressionDescriptor CreateDescriptor(string type, IExpressionHandler handler) + { + return new() + { + Type = type, + HandlerFactory = _ => handler + }; + } + + private static IExpressionDescriptorProvider CreateProvider(params ExpressionDescriptor[] descriptors) + { + var provider = Substitute.For(); + provider.GetDescriptors().Returns(descriptors); + return provider; + } + + private static ExpressionDescriptorRegistry CreateRegistry(params IExpressionDescriptorProvider[] providers) + { + return new(providers); + } +}