diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index de0b29cc1..81feb0519 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -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.
Integration tests only | diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs index 25e8b3ce8..429ce6f99 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs @@ -35,11 +35,15 @@ public static class RunWorkflowExtensions { var workflowDefinitionService = services.GetRequiredService(); 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(); 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 }); diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs index fc5f5bff3..3f9d03d9d 100644 --- a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs +++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs @@ -17,4 +17,25 @@ public static class ActivityExecutionContextExtensions { return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null; } + + /// + /// Retrieves the collection of outcomes recorded in the execution context's journal data. + /// + /// The activity execution context from which to retrieve the outcomes. + /// A collection of outcome strings, or an empty collection if no outcomes are present. + public static IEnumerable GetOutcomes(this ActivityExecutionContext activityExecutionContext) + { + return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : []; + } + + /// + /// Determines whether the specified outcome exists in the current activity execution context. + /// + /// The activity execution context to check for the outcome. + /// The outcome to verify within the execution context. + /// True if the specified outcome exists; otherwise, false. + public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome) + { + return activityExecutionContext.GetOutcomes().Contains(outcome); + } } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs index 8812a63b2..44a525cb9 100644 --- a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -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(); services.AddSingleton(); } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs index 6a443629d..c1f38f9ae 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs @@ -75,6 +75,6 @@ public class ConfigureEngineWithVariablesAndInputOutputAccessors(IOptions)(() => context.GetOutput(activityOutput.ActivityId, outputName))); + engine.SetValue($"get{outputName}From{activityOutput.ActivityName.Pascalize()}", (Func)(() => context.GetOutput(activityOutput.ActivityId, outputName))); } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index f3487fa1f..08c0420f9 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -35,5 +35,32 @@ Always + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs index 45b4a0dca..f90bd7c9f 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs @@ -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. diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs new file mode 100644 index 000000000..453054974 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs @@ -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 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"]; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json new file mode 100644 index 000000000..23cf02e78 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json @@ -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" + } + } + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json new file mode 100644 index 000000000..3b85bc4bf --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json @@ -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": [] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json new file mode 100644 index 000000000..6a4aeeba8 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json @@ -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": [] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json new file mode 100644 index 000000000..fab43afa4 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json @@ -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": [] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json new file mode 100644 index 000000000..b750cb617 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json @@ -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" + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json new file mode 100644 index 000000000..2293ba19f --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json @@ -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": [] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json new file mode 100644 index 000000000..526e7396b --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json @@ -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" + } + } + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json new file mode 100644 index 000000000..887f6f04e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json @@ -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": [] + } + ] + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json new file mode 100644 index 000000000..e1c7dd555 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json @@ -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": [] + } + ] + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs new file mode 100644 index 000000000..8d1f74580 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs @@ -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 BranchTestCases() + { + yield return [Array.Empty(), new[] { "Done" }]; + yield return [new[] { "SingleBranch" }, new[] { "SingleBranch" }]; + yield return [new[] { "Branch1", "Branch2", "Branch3" }, new[] { "Branch1", "Branch2", "Branch3" }]; + } + + private static Task ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs new file mode 100644 index 000000000..bbaff3172 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs @@ -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 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 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 + { + 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 DefaultOutcomeTestCases() + { + yield return [new List()]; + yield return + [ + new List + { + new("Case1", () => false), + new("Case2", () => false) + } + ]; + } + + public static IEnumerable SwitchModeTestCases() + { + var cases = new List + { + 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 ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs new file mode 100644 index 000000000..c0758bbe4 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs @@ -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() : null; + var switchActivity = new Switch + { + Mode = new(mode), + Cases = new List + { + new("False Case", Expression.LiteralExpression(false), Substitute.For()) + }, + 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(); + var secondTrueActivity = Substitute.For(); + var switchActivity = new Switch + { + Mode = new(mode), + Cases = new List + { + new("False", Expression.LiteralExpression(false), Substitute.For()), + 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(); + var secondTrueActivity = Substitute.For(); + var switchActivity = new Switch + { + // No Mode explicitly set - should default to MatchFirst + Cases = new List + { + 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(); + var switchActivity = new Switch + { + Mode = new(mode), + Cases = new List + { + new("Null condition", Expression.LiteralExpression(null), Substitute.For()) + }, + 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() : null; + var switchActivity = new Switch + { + Mode = new(mode), + Cases = new List(), + 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())); + 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 ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs new file mode 100644 index 000000000..5dfa4a3f4 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs @@ -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 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(), 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("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("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 + { + { "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; + 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(content) : null!, + ContentType = contentType != null ? new Input(contentType) : null!, + Authorization = authorization != null ? new Input(authorization) : null!, + ExpectedStatusCodes = expectedStatusCodes != null ? new Input>(expectedStatusCodes) : null! + }; + } + + private static Task ExecuteActivityAsync( + FlowSendHttpRequest flowSendHttpRequest, + Func> responseHandler) + { + return new ActivityTestFixture(flowSendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs similarity index 99% rename from test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs rename to test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs index ea6b8de4f..0cc07b367 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs @@ -9,7 +9,7 @@ using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; using NSubstitute; -namespace Elsa.Activities.UnitTests.Helpers; +namespace Elsa.Activities.UnitTests.Http.Helpers; /// /// Extension methods for configuring HTTP-related services in ActivityTestFixture. diff --git a/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs new file mode 100644 index 000000000..f654201ac --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs @@ -0,0 +1,43 @@ +using System.Net; + +namespace Elsa.Activities.UnitTests.Http.Helpers; + +/// +/// Shared helper methods for testing SendHttpRequest and FlowSendHttpRequest activities. +/// +public static class SendHttpRequestTestHelpers +{ + /// + /// Creates a response handler that returns a specific HTTP status code and optional content. + /// + public 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)); + }; + } + + /// + /// Creates an exception handler that throws a specific exception type with a message. + /// + public static Func> CreateExceptionHandler(string message) + where TException : Exception + { + return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!); + } + + /// + /// Captures HTTP request details during test execution. + /// + public sealed class RequestCapture + { + public HttpRequestMessage? CapturedRequest { get; set; } + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs similarity index 93% rename from test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs rename to test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs index 0b9f1843d..2eb0fbfbb 100644 --- a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs @@ -1,4 +1,4 @@ -namespace Elsa.Activities.UnitTests.Helpers; +namespace Elsa.Activities.UnitTests.Http.Helpers; /// /// Custom test HTTP message handler that allows full control over HTTP responses for testing. diff --git a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs index d14199041..0b032f7a2 100644 --- a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs @@ -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;