Merge branch 'develop/3.6.0' into feat/unit-test-coverage-if

This commit is contained in:
lucas.hipolito 2025-10-21 15:45:15 +02:00
commit 5ba0fbab56
25 changed files with 1355 additions and 9 deletions

View file

@ -160,6 +160,42 @@ public async Task Should_Schedule_Child_Activity()
}
```
**Example (checking activity outcomes):**
```csharp
[Fact]
public async Task Should_Return_Multiple_Outcomes()
{
// Arrange
var flowFork = new FlowFork
{
Branches = new(new[] { "Branch1", "Branch2", "Branch3" })
};
// Act
var context = await new ActivityTestFixture(flowFork).ExecuteAsync();
// Assert - Check all outcomes
var outcomes = context.GetOutcomes().ToList();
Assert.Equal(3, outcomes.Count);
Assert.Contains("Branch1", outcomes);
Assert.Contains("Branch2", outcomes);
Assert.Contains("Branch3", outcomes);
}
[Fact]
public async Task Should_Return_Default_Outcome()
{
// Arrange
var flowSwitch = new FlowSwitch();
// Act
var context = await new ActivityTestFixture(flowSwitch).ExecuteAsync();
// Assert - Check single outcome
Assert.True(context.HasOutcome("Default"));
}
```
#### **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.
@ -214,6 +250,8 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status);
| `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 |
| `context.GetOutcomes` | Get all outcomes from activity execution | Asserting multiple outcomes in unit tests |
| `context.HasOutcome` | Check if activity has specific outcome | Asserting single outcome 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. <br/>Integration tests only |

View file

@ -35,11 +35,15 @@ public static class RunWorkflowExtensions
{
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, versionOptions ?? VersionOptions.Published);
if (workflowGraph == null)
throw new InvalidOperationException($"Workflow definition with ID '{workflowDefinitionId}' not found.");
var workflowRuntime = services.GetRequiredService<IWorkflowRuntime>();
var workflowClient = await workflowRuntime.CreateClientAsync();
var response = await workflowClient.CreateAndRunInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph!.Workflow.Identity.Id),
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
Input = input
});

View file

@ -17,4 +17,25 @@ public static class ActivityExecutionContextExtensions
{
return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
}
/// <summary>
/// Retrieves the collection of outcomes recorded in the execution context's journal data.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context from which to retrieve the outcomes.</param>
/// <returns>A collection of outcome strings, or an empty collection if no outcomes are present.</returns>
public static IEnumerable<string> GetOutcomes(this ActivityExecutionContext activityExecutionContext)
{
return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
}
/// <summary>
/// Determines whether the specified outcome exists in the current activity execution context.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context to check for the outcome.</param>
/// <param name="outcome">The outcome to verify within the execution context.</param>
/// <returns>True if the specified outcome exists; otherwise, false.</returns>
public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome)
{
return activityExecutionContext.GetOutcomes().Contains(outcome);
}
}

View file

@ -83,6 +83,7 @@ public class ActivityTestFixture
// Set up variables and inputs, then execute the activity
await SetupExistingVariablesAsync(Activity, context);
await context.EvaluateInputPropertiesAsync();
context.TransitionTo(ActivityStatus.Running);
await Activity.ExecuteAsync(context);
return context;
@ -167,4 +168,4 @@ public class ActivityTestFixture
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, FakeWorkflowExecutionContextSchedulerStrategy>();
services.AddSingleton<IActivityExecutionContextSchedulerStrategy, FakeActivityExecutionContextSchedulerStrategy>();
}
}
}

View file

@ -75,6 +75,6 @@ public class ConfigureEngineWithVariablesAndInputOutputAccessors(IOptions<JintOp
await foreach (var activityOutput in activityOutputs)
foreach (var outputName in activityOutput.OutputNames.FilterInvalidVariableNames())
engine.SetValue($"get{outputName}From{activityOutput.ActivityName}", (Func<object?>)(() => context.GetOutput(activityOutput.ActivityId, outputName)));
engine.SetValue($"get{outputName}From{activityOutput.ActivityName.Pascalize()}", (Func<object?>)(() => context.GetOutput(activityOutput.ActivityId, outputName)));
}
}

View file

@ -35,5 +35,32 @@
<None Update="Scenarios\JoinBehaviors\Workflows\decision-merge-join-none.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\parent-with-input.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\child-with-input.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\child-with-outcomes.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\child-with-output.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\parent-version-fallback.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\parent-with-io.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\parent-with-outcomes.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\parent-with-output.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\child-published-version.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -17,12 +17,13 @@ public class Tests
}
[Fact(DisplayName = "Workflows provided from JSON files that depend on other workflows are executed correctly.")]
public async Task Test1()
public async Task Should_Execute_Multi_Level_Nested_Workflow_Definitions()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Run the "matter" workflow.
// Run the "matter" workflow which contains MoleculeActivity, which contains AtomActivity.
// Matter -> 3x Molecule -> each Molecule contains 3x Atom = 9 "Atom" outputs total
await _services.RunWorkflowUntilEndAsync("matter-workflow");
// Assert.

View file

@ -0,0 +1,41 @@
using Elsa.Testing.Shared;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionActivities;
public class Tests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public Tests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.WithWorkflowsFromDirectory("Scenarios", "WorkflowDefinitionActivities", "Workflows")
.Build();
}
[Theory]
[MemberData(nameof(WorkflowDefinitionActivityTestCases))]
public async Task Should_Execute_WorkflowDefinitionActivity_Scenarios(string workflowDefinitionId, string expectedOutput)
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Run the workflow.
await _services.RunWorkflowUntilEndAsync(workflowDefinitionId);
// Assert - verify expected output appears.
var lines = _capturingTextWriter.Lines.ToList();
Assert.Contains(expectedOutput, lines);
}
public static IEnumerable<object[]> WorkflowDefinitionActivityTestCases()
{
yield return ["parent-with-input", "Hello from parent!"];
yield return ["parent-with-output", "Received from child: Child output value"];
yield return ["parent-with-outcomes", "Success path taken"];
yield return ["parent-version-fallback", "Published version executed"];
}
}

View file

@ -0,0 +1,23 @@
{
"id": "child-published-v1",
"definitionId": "child-published",
"name": "ChildPublished",
"version": 1,
"isLatest": true,
"isPublished": true,
"usableAsActivity": true,
"inputs": [],
"outputs": [],
"root": {
"type": "Elsa.WriteLine",
"version": 1,
"id": "WriteLine1",
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Published version executed"
}
}
}
}

View file

@ -0,0 +1,79 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "e694d38a6d17f6ab",
"definitionId": "d0ae88e4c15eacc0",
"name": "ChildWithInput",
"createdAt": "2025-10-21T12:06:12.52497\u002B00:00",
"version": 1,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [
{
"uiHint": "singleline",
"storageDriverType": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core",
"type": "String",
"name": "Message",
"displayName": "Message",
"description": "The message to write",
"category": "Primitives"
}
],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"usableAsActivity": true,
"autoUpdateConsumingWorkflows": true
},
"root": {
"id": "855226c76e9ad551",
"nodeId": "Workflow1:855226c76e9ad551",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "JavaScript",
"value": "getMessage()"
}
},
"id": "eff084a902159510",
"nodeId": "Workflow1:855226c76e9ad551:eff084a902159510",
"name": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -278.5,
"y": -178.5
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": []
}
}

View file

@ -0,0 +1,72 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "85761829037332b7",
"definitionId": "7ee5c04453217e3f",
"name": "ChildWithOutcomes",
"createdAt": "2025-10-21T13:11:59.899105\u002B00:00",
"version": 2,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [
"Success",
"Failed"
],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"usableAsActivity": true,
"autoUpdateConsumingWorkflows": true
},
"root": {
"id": "3519519e32e50f38",
"nodeId": "Workflow1:3519519e32e50f38",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"outcomes": {
"typeName": "Object",
"expression": {
"type": "Literal",
"value": "Success"
}
},
"id": "a093b294de68dc32",
"nodeId": "Workflow1:3519519e32e50f38:a093b294de68dc32",
"name": "Complete1",
"type": "Elsa.Complete",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -160.5,
"y": -143.5
},
"size": {
"width": 157.15625,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": []
}
}

View file

@ -0,0 +1,84 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "4da8180d0c9f7e3f",
"definitionId": "5781ed56ea4b160",
"name": "ChildWithOutput",
"createdAt": "2025-10-21T12:15:08.404102\u002B00:00",
"version": 4,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [
{
"type": "String",
"name": "Output",
"displayName": "Output",
"description": "The result value",
"category": "Primitives"
}
],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"usableAsActivity": true,
"autoUpdateConsumingWorkflows": true
},
"root": {
"id": "14493561a73301af",
"nodeId": "Workflow1:14493561a73301af",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"outputName": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Output"
}
},
"outputValue": {
"typeName": "Object",
"expression": {
"type": "Literal",
"value": "Child output value"
}
},
"id": "461b5cebad79475c",
"nodeId": "Workflow1:14493561a73301af:461b5cebad79475c",
"name": "SetOutput1",
"type": "Elsa.SetOutput",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -353.5,
"y": -235.5
},
"size": {
"width": 132.3203125,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": []
}
}

View file

@ -0,0 +1,23 @@
{
"id": "parent-version-fallback-v1",
"definitionId": "parent-version-fallback",
"name": "ParentVersionFallback",
"version": 1,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"start": "ChildActivity1",
"activities": [
{
"type": "ChildPublished",
"version": 1,
"id": "ChildActivity1",
"workflowDefinitionId": "child-published",
"workflowDefinitionVersionId": "non-existent-version-id"
}
]
}
}

View file

@ -0,0 +1,74 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "70a093d85f846d05",
"definitionId": "parent-with-input",
"name": "ParentWithInput",
"createdAt": "2025-10-21T12:08:40.940992\u002B00:00",
"version": 4,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "Flowchart1",
"nodeId": "Workflow2:Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"workflowDefinitionId": "d0ae88e4c15eacc0",
"workflowDefinitionVersionId": "e694d38a6d17f6ab",
"latestAvailablePublishedVersion": 1,
"latestAvailablePublishedVersionId": "e694d38a6d17f6ab",
"id": "ChildActivity1",
"nodeId": "Workflow2:Flowchart1:ChildActivity1",
"name": null,
"type": "ChildWithInput",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -80,
"y": -40
},
"size": {
"width": 161.8515625,
"height": 67.9765625
}
}
},
"message": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Hello from parent!"
},
"memoryReference": {
"id": "ChildActivity1:input-message"
}
}
}
],
"variables": [],
"connections": []
}
}

View file

@ -0,0 +1,23 @@
{
"id": "parent-with-io-v1",
"definitionId": "parent-with-io",
"name": "ParentWithIO",
"version": 1,
"toolVersion": "3.6.0.0",
"isLatest": true,
"isPublished": true,
"root": {
"type": "ChildWithInput",
"version": 1,
"id": "ChildActivity1",
"workflowDefinitionId": "child-with-input",
"workflowDefinitionVersionId": "child-with-input-v1",
"message": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Test message"
}
}
}
}

View file

@ -0,0 +1,147 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "a09fb3bdfd316332",
"definitionId": "parent-with-outcomes",
"name": "ParentWithOutcomes",
"createdAt": "2025-10-21T13:11:28.283567\u002B00:00",
"version": 2,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "Flowchart1",
"nodeId": "Workflow2:Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"workflowDefinitionId": "7ee5c04453217e3f",
"workflowDefinitionVersionId": "85761829037332b7",
"latestAvailablePublishedVersion": 2,
"latestAvailablePublishedVersionId": "85761829037332b7",
"id": "ChildActivity1",
"nodeId": "Workflow2:Flowchart1:ChildActivity1",
"name": null,
"type": "ChildWithOutcomes",
"version": 2,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -140,
"y": 0
},
"size": {
"width": 198.7421875,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Failed path taken"
}
},
"id": "WriteLineFailed",
"nodeId": "Workflow2:Flowchart1:WriteLineFailed",
"name": null,
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 140,
"y": 80
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Success path taken"
}
},
"id": "WriteLineSuccess",
"nodeId": "Workflow2:Flowchart1:WriteLineSuccess",
"name": null,
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 180,
"y": -220
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "ChildActivity1",
"port": "Success"
},
"target": {
"activity": "WriteLineSuccess",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "ChildActivity1",
"port": "Failed"
},
"target": {
"activity": "WriteLineFailed",
"port": "In"
},
"vertices": []
}
]
}
}

View file

@ -0,0 +1,107 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "b1f4fc14b3c6b51",
"definitionId": "parent-with-output",
"name": "ParentWithOutput",
"createdAt": "2025-10-21T12:27:50.918094\u002B00:00",
"version": 1,
"toolVersion": "3.6.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "bcbe1e9e5a9c9b0e",
"nodeId": "Workflow2:bcbe1e9e5a9c9b0e",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"workflowDefinitionId": "5781ed56ea4b160",
"workflowDefinitionVersionId": "4da8180d0c9f7e3f",
"latestAvailablePublishedVersion": 4,
"latestAvailablePublishedVersionId": "4da8180d0c9f7e3f",
"id": "c98383464dab2047",
"nodeId": "Workflow2:bcbe1e9e5a9c9b0e:c98383464dab2047",
"name": "ChildWithOutput1",
"type": "ChildWithOutput",
"version": 4,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -323.5,
"y": -85.5
},
"size": {
"width": 174.1640625,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "JavaScript",
"value": "\u0060Received from child: ${getOutputFromChildWithOutput1()}\u0060"
}
},
"id": "b4f314dce11c7135",
"nodeId": "Workflow2:bcbe1e9e5a9c9b0e:b4f314dce11c7135",
"name": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -27.5,
"y": -85.5
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "c98383464dab2047",
"port": "Done"
},
"target": {
"activity": "b4f314dce11c7135",
"port": "In"
},
"vertices": []
}
]
}
}

View file

@ -0,0 +1,40 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Activities;
namespace Elsa.Activities.UnitTests.Branching;
public class FlowForkTests
{
[Theory]
[MemberData(nameof(BranchTestCases))]
public async Task Should_Complete_With_Specified_Branches(string[] branches, string[] expectedOutcomes)
{
// Arrange
var flowFork = new FlowFork();
if (branches.Length > 0)
flowFork.Branches = new(branches);
// Act
var context = await ExecuteAsync(flowFork);
// Assert - Activity should complete with all outcomes.
var outcomes = context.GetOutcomes().ToList();
Assert.Equal(expectedOutcomes.Length, outcomes.Count);
foreach (var expectedOutcome in expectedOutcomes)
Assert.Contains(expectedOutcome, outcomes);
}
public static IEnumerable<object[]> BranchTestCases()
{
yield return [Array.Empty<string>(), new[] { "Done" }];
yield return [new[] { "SingleBranch" }, new[] { "SingleBranch" }];
yield return [new[] { "Branch1", "Branch2", "Branch3" }, new[] { "Branch1", "Branch2", "Branch3" }];
}
private static Task<ActivityExecutionContext> ExecuteAsync(IActivity activity)
{
return new ActivityTestFixture(activity).ExecuteAsync();
}
}

View file

@ -0,0 +1,99 @@
using Elsa.Expressions.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
namespace Elsa.Activities.UnitTests.Branching;
public class FlowSwitchTests
{
[Theory]
[MemberData(nameof(DefaultOutcomeTestCases))]
public async Task Should_Return_Default_When_No_Cases_Match(List<FlowSwitchCase> cases)
{
// Arrange
var flowSwitch = new FlowSwitch { Cases = cases };
// Act
var context = await ExecuteAsync(flowSwitch);
// Assert - Activity should return Default outcome when no cases match.
Assert.True(context.HasOutcome("Default"));
}
[Theory]
[MemberData(nameof(SwitchModeTestCases))]
public async Task Should_Return_Outcomes_Based_On_Switch_Mode(
List<FlowSwitchCase> cases,
SwitchMode mode,
string[] expectedOutcomes)
{
// Arrange
var flowSwitch = new FlowSwitch
{
Cases = cases,
Mode = new(mode)
};
// Act
var context = await ExecuteAsync(flowSwitch);
// Assert - Activity should return outcomes based on switch mode.
var outcomes = context.GetOutcomes().ToList();
Assert.Equal(expectedOutcomes.Length, outcomes.Count);
foreach (var expectedOutcome in expectedOutcomes)
Assert.Contains(expectedOutcome, outcomes);
}
[Fact]
public async Task Should_Evaluate_Cases_With_Literal_Expression()
{
// Arrange
var flowSwitch = new FlowSwitch
{
Cases = new List<FlowSwitchCase>
{
new("TrueCase", Expression.LiteralExpression(true)),
new("FalseCase", Expression.LiteralExpression(false))
}
};
// Act
var context = await ExecuteAsync(flowSwitch);
// Assert - Activity should match the case with true literal.
Assert.True(context.HasOutcome("TrueCase"));
}
public static IEnumerable<object[]> DefaultOutcomeTestCases()
{
yield return [new List<FlowSwitchCase>()];
yield return
[
new List<FlowSwitchCase>
{
new("Case1", () => false),
new("Case2", () => false)
}
];
}
public static IEnumerable<object[]> SwitchModeTestCases()
{
var cases = new List<FlowSwitchCase>
{
new("Case1", () => false),
new("Case2", () => true),
new("Case3", () => true)
};
yield return [cases, SwitchMode.MatchFirst, new[] { "Case2" }];
yield return [cases, SwitchMode.MatchAny, new[] { "Case2", "Case3" }];
}
private static Task<ActivityExecutionContext> ExecuteAsync(IActivity activity)
{
return new ActivityTestFixture(activity).ExecuteAsync();
}
}

View file

@ -0,0 +1,194 @@
using Elsa.Expressions.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Branching;
public class SwitchTests
{
[Theory]
[InlineData(SwitchMode.MatchFirst, true)]
[InlineData(SwitchMode.MatchAny, true)]
[InlineData(SwitchMode.MatchFirst, false)]
[InlineData(SwitchMode.MatchAny, false)]
public async Task Should_Handle_No_Matching_Cases_Correctly(SwitchMode mode, bool hasDefault)
{
// Arrange
var defaultActivity = hasDefault ? Substitute.For<IActivity>() : null;
var switchActivity = new Switch
{
Mode = new(mode),
Cases = new List<SwitchCase>
{
new("False Case", Expression.LiteralExpression(false), Substitute.For<IActivity>())
},
Default = defaultActivity
};
// Act
var context = await ExecuteAsync(switchActivity);
// Assert
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
if (hasDefault)
{
Assert.Single(scheduledActivities);
Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
}
else
{
Assert.Empty(scheduledActivities);
}
}
[Theory]
[InlineData(SwitchMode.MatchFirst, 1)]
[InlineData(SwitchMode.MatchAny, 2)]
public async Task Should_Handle_Multiple_Matching_Cases_According_To_Mode(SwitchMode mode, int expectedScheduledCount)
{
// Arrange
var firstTrueActivity = Substitute.For<IActivity>();
var secondTrueActivity = Substitute.For<IActivity>();
var switchActivity = new Switch
{
Mode = new(mode),
Cases = new List<SwitchCase>
{
new("False", Expression.LiteralExpression(false), Substitute.For<IActivity>()),
new("First True", Expression.LiteralExpression(true), firstTrueActivity),
new("Second True", Expression.LiteralExpression(true), secondTrueActivity)
}
};
// Act
var context = await ExecuteAsync(switchActivity);
// Assert
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
Assert.Equal(expectedScheduledCount, scheduledActivities.Count);
if (mode == SwitchMode.MatchFirst)
{
Assert.Equal(firstTrueActivity, scheduledActivities.First().Activity);
}
else // MatchAny
{
Assert.Contains(scheduledActivities, s => s.Activity == firstTrueActivity);
Assert.Contains(scheduledActivities, s => s.Activity == secondTrueActivity);
}
}
[Fact]
public async Task Should_Use_MatchFirst_As_Default_Mode()
{
// Arrange
var firstTrueActivity = Substitute.For<IActivity>();
var secondTrueActivity = Substitute.For<IActivity>();
var switchActivity = new Switch
{
// No Mode explicitly set - should default to MatchFirst
Cases = new List<SwitchCase>
{
new("First True", Expression.LiteralExpression(true), firstTrueActivity),
new("Second True", Expression.LiteralExpression(true), secondTrueActivity)
}
};
// Act
var context = await ExecuteAsync(switchActivity);
// Assert
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
Assert.Single(scheduledActivities);
Assert.Equal(firstTrueActivity, scheduledActivities.First().Activity);
}
[Theory]
[InlineData(SwitchMode.MatchFirst)]
[InlineData(SwitchMode.MatchAny)]
public async Task Should_Schedule_Default_When_Null_Case_Condition_Evaluates_False(SwitchMode mode)
{
// Arrange
var defaultActivity = Substitute.For<IActivity>();
var switchActivity = new Switch
{
Mode = new(mode),
Cases = new List<SwitchCase>
{
new("Null condition", Expression.LiteralExpression(null), Substitute.For<IActivity>())
},
Default = defaultActivity
};
// Act
var context = await ExecuteAsync(switchActivity);
// Assert
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
Assert.Single(scheduledActivities);
Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
}
[Theory]
[InlineData(SwitchMode.MatchFirst, true)]
[InlineData(SwitchMode.MatchAny, true)]
[InlineData(SwitchMode.MatchFirst, false)]
[InlineData(SwitchMode.MatchAny, false)]
public async Task Should_Handle_Empty_Cases_Correctly(SwitchMode mode, bool hasDefault)
{
// Arrange
var defaultActivity = hasDefault ? Substitute.For<IActivity>() : null;
var switchActivity = new Switch
{
Mode = new(mode),
Cases = new List<SwitchCase>(),
Default = defaultActivity
};
// Act
var context = await ExecuteAsync(switchActivity);
// Assert
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
if (hasDefault)
{
Assert.Single(scheduledActivities);
Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
}
else
{
Assert.Empty(scheduledActivities);
}
}
[Fact]
public void Should_Initialize_Cases_Collection_By_Default()
{
// Arrange & Act
var switchActivity = new Switch();
// Assert
Assert.NotNull(switchActivity.Cases);
Assert.Empty(switchActivity.Cases);
switchActivity.Cases.Add(new("Test", Expression.LiteralExpression(true), Substitute.For<IActivity>()));
Assert.Single(switchActivity.Cases);
}
[Fact]
public void Should_Initialize_Mode_To_MatchFirst_By_Default()
{
// Arrange
var switchActivity = new Switch();
// Assert
Assert.NotNull(switchActivity.Mode);
// The actual default value verification is handled by the mode-specific behavior tests
}
private static Task<ActivityExecutionContext> ExecuteAsync(IActivity activity)
{
return new ActivityTestFixture(activity).ExecuteAsync();
}
}

View file

@ -0,0 +1,206 @@
using System.Net;
using Elsa.Activities.UnitTests.Http.Helpers;
using Elsa.Extensions;
using Elsa.Http;
using Elsa.Testing.Shared;
using Elsa.Workflows;
namespace Elsa.Activities.UnitTests.Http;
public class FlowSendHttpRequestTests
{
[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_Set_Status_Code_Output(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 SendHttpRequestTestHelpers.RequestCapture();
var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture);
var flowSendHttpRequest = CreateFlowSendHttpRequest(expectedUrl, method);
// Act
var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert
Assert.NotNull(requestCapture.CapturedRequest);
Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method);
Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri);
var statusCodeOutput = context.GetActivityOutput(() => flowSendHttpRequest.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 SendHttpRequestTestHelpers.RequestCapture();
var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(HttpStatusCode.OK, null, requestCapture);
var flowSendHttpRequest = CreateFlowSendHttpRequest(expectedUrl, authorization: authorizationHeader);
// Act
await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert
Assert.NotNull(requestCapture.CapturedRequest);
Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization);
Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString());
}
[Theory]
[MemberData(nameof(StatusCodeOutcomeTestCases))]
public async Task Should_Return_Outcome_Based_On_Status_Code(
int[] expectedStatusCodes,
HttpStatusCode actualStatusCode,
string[] expectedOutcomes)
{
// Arrange
var flowSendHttpRequest = CreateFlowSendHttpRequest(
new("https://api.example.com/test"),
expectedStatusCodes: expectedStatusCodes);
var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(actualStatusCode);
// Act
var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert - Activity should return outcomes based on status code match.
var outcomes = context.GetOutcomes().ToList();
Assert.Equal(expectedOutcomes.Length, outcomes.Count);
foreach (var expectedOutcome in expectedOutcomes)
Assert.Contains(expectedOutcome, outcomes);
}
public static IEnumerable<object[]> StatusCodeOutcomeTestCases()
{
// Expected status codes: 200, 404
// Status code matches - returns status code + Done
yield return [new[] { 200, 404 }, HttpStatusCode.OK, new[] { "200", "Done" }];
yield return [new[] { 200, 404 }, HttpStatusCode.NotFound, new[] { "404", "Done" }];
// Status code doesn't match - returns "Unmatched status code" + Done
yield return [new[] { 200, 404 }, HttpStatusCode.InternalServerError, new[] { "Unmatched status code", "Done" }];
// No expected status codes - returns only Done
yield return [Array.Empty<int>(), HttpStatusCode.OK, new[] { "Done" }];
}
[Fact]
public async Task Should_Return_FailedToConnect_Outcome_On_HttpRequestException()
{
// Arrange
var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/error"));
var responseHandler = SendHttpRequestTestHelpers.CreateExceptionHandler<HttpRequestException>("Connection failed");
// Act
var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert
Assert.True(context.HasOutcome("Failed to connect"));
}
[Fact]
public async Task Should_Return_Timeout_Outcome_On_TaskCanceledException()
{
// Arrange
var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/timeout"));
var responseHandler = SendHttpRequestTestHelpers.CreateExceptionHandler<TaskCanceledException>("Request timed out");
// Act
var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert
Assert.True(context.HasOutcome("Timeout"));
}
[Fact]
public async Task Should_Set_Response_Headers_Output()
{
// Arrange
var expectedHeaders = new Dictionary<string, string>
{
{ "Custom-Header", "CustomValue" },
{ "X-Rate-Limit", "100" }
};
var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders);
var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/headers"));
// Act
var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
// Assert
var responseHeadersObj = context.GetActivityOutput(() => flowSendHttpRequest.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 FlowSendHttpRequest());
fixture.AssertActivityAttributes(
expectedNamespace: "Elsa",
expectedCategory: "HTTP",
expectedDisplayName: "HTTP Request (flow)",
expectedDescription: "Send an HTTP request.",
expectedKind: ActivityKind.Task
);
}
[Fact]
public void Should_Have_Default_Expected_Status_Codes()
{
// Arrange
var flowSendHttpRequest = new FlowSendHttpRequest();
// Act - Get the default value
var defaultValue = ((IActivityPropertyDefaultValueProvider)flowSendHttpRequest)
.GetDefaultValue(typeof(FlowSendHttpRequest).GetProperty(nameof(FlowSendHttpRequest.ExpectedStatusCodes))!);
// Assert
var defaultStatusCodes = defaultValue as List<int>;
Assert.NotNull(defaultStatusCodes);
Assert.Single(defaultStatusCodes);
Assert.Equal(200, defaultStatusCodes.First());
}
// Private helper methods
private static FlowSendHttpRequest CreateFlowSendHttpRequest(
Uri url,
string method = "GET",
object? content = null,
string? contentType = null,
string? authorization = null,
int[]? expectedStatusCodes = null)
{
return new()
{
Url = new(url),
Method = new(method),
Content = content != null ? new Input<object?>(content) : null!,
ContentType = contentType != null ? new Input<string?>(contentType) : null!,
Authorization = authorization != null ? new Input<string?>(authorization) : null!,
ExpectedStatusCodes = expectedStatusCodes != null ? new Input<ICollection<int>>(expectedStatusCodes) : null!
};
}
private static Task<ActivityExecutionContext> ExecuteActivityAsync(
FlowSendHttpRequest flowSendHttpRequest,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseHandler)
{
return new ActivityTestFixture(flowSendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync();
}
}

View file

@ -9,7 +9,7 @@ using Elsa.Workflows;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Helpers;
namespace Elsa.Activities.UnitTests.Http.Helpers;
/// <summary>
/// Extension methods for configuring HTTP-related services in ActivityTestFixture.

View file

@ -0,0 +1,43 @@
using System.Net;
namespace Elsa.Activities.UnitTests.Http.Helpers;
/// <summary>
/// Shared helper methods for testing SendHttpRequest and FlowSendHttpRequest activities.
/// </summary>
public static class SendHttpRequestTestHelpers
{
/// <summary>
/// Creates a response handler that returns a specific HTTP status code and optional content.
/// </summary>
public static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateResponseHandler(
HttpStatusCode statusCode,
string? content = null,
RequestCapture? requestCapture = null,
Dictionary<string, string>? additionalHeaders = null)
{
return (request, _) =>
{
if (requestCapture != null)
requestCapture.CapturedRequest = request;
return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders));
};
}
/// <summary>
/// Creates an exception handler that throws a specific exception type with a message.
/// </summary>
public static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateExceptionHandler<TException>(string message)
where TException : Exception
{
return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!);
}
/// <summary>
/// Captures HTTP request details during test execution.
/// </summary>
public sealed class RequestCapture
{
public HttpRequestMessage? CapturedRequest { get; set; }
}
}

View file

@ -1,4 +1,4 @@
namespace Elsa.Activities.UnitTests.Helpers;
namespace Elsa.Activities.UnitTests.Http.Helpers;
/// <summary>
/// Custom test HTTP message handler that allows full control over HTTP responses for testing.

View file

@ -1,6 +1,5 @@
using System.Net;
using System.Text;
using Elsa.Activities.UnitTests.Helpers;
using Elsa.Activities.UnitTests.Http.Helpers;
using Elsa.Extensions;
using Elsa.Http;
using Elsa.Testing.Shared;