diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/ExecuteWorkflowTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/ExecuteWorkflowTests.cs new file mode 100644 index 000000000..dd0413fe3 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/ExecuteWorkflowTests.cs @@ -0,0 +1,112 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.State; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Activities.ExecuteWorkflow; + +public class ExecuteWorkflowTests +{ + private readonly IServiceProvider _services; + private readonly CapturingTextWriter _capturingTextWriter = new(); + + public ExecuteWorkflowTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .WithWorkflowsFromDirectory("Activities", "ExecuteWorkflow", "Workflows") + .Build(); + } + + [Fact(DisplayName = "ExecuteWorkflow without WaitForCompletion completes immediately")] + public async Task ExecuteWorkflowWithoutWaitForCompletion() + { + var workflowState = await RunWorkflowAsync("parent-no-wait"); + + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, workflowState.SubStatus); + + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Contains("Parent: Before child", lines); + Assert.Contains("Child: Executing", lines); + Assert.Contains("Parent: After child", lines); + } + + [Fact(DisplayName = "ExecuteWorkflow with WaitForCompletion waits for child to finish")] + public async Task ExecuteWorkflowWithWaitForCompletion() + { + var workflowState = await RunWorkflowAsync("parent-with-wait"); + + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, workflowState.SubStatus); + + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(3, lines.Count); + Assert.Equal("Parent: Before child", lines[0]); + Assert.Equal("Child: Executing", lines[1]); + Assert.Equal("Parent: After child", lines[2]); + } + + [Fact(DisplayName = "ExecuteWorkflow passes input to child workflow")] + public async Task ExecuteWorkflowPassesInput() + { + var workflowState = await RunWorkflowAsync("parent-with-input"); + + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Contains("Child received: Hello from parent", lines); + } + + [Fact(DisplayName = "ExecuteWorkflow captures child workflow output")] + public async Task ExecuteWorkflowCapturesOutput() + { + var workflowState = await RunWorkflowAsync("parent-capture-output"); + + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Contains("Child output: 42", lines); + Assert.Contains("Parent received: 42", lines); + } + + [Fact(DisplayName = "ExecuteWorkflow sets correlation ID on child workflow")] + public async Task ExecuteWorkflowSetsCorrelationId() + { + var workflowState = await RunWorkflowAsync("parent-with-correlation"); + + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + + var childInstance = await GetWorkflowInstanceAsync("child-workflow"); + Assert.Equal("test-correlation-123", childInstance.CorrelationId); + } + + [Fact(DisplayName = "ExecuteWorkflow includes ParentInstanceId in child properties")] + public async Task ExecuteWorkflowSetsParentInstanceId() + { + await RunWorkflowAsync("parent-no-wait"); + + var parentInstance = await GetWorkflowInstanceAsync("parent-no-wait"); + var childInstance = await GetWorkflowInstanceAsync("child-workflow"); + + Assert.True(childInstance.WorkflowState.Properties.ContainsKey("ParentInstanceId")); + Assert.Equal(parentInstance.Id, childInstance.WorkflowState.Properties["ParentInstanceId"]); + } + + private async Task RunWorkflowAsync(string workflowDefinitionId) + { + await _services.PopulateRegistriesAsync(); + return await _services.RunWorkflowUntilEndAsync(workflowDefinitionId); + } + + private async Task GetWorkflowInstanceAsync(string definitionId) + { + var workflowInstanceStore = _services.GetRequiredService(); + return (await workflowInstanceStore.FindAsync(new() + { + DefinitionId = definitionId + }))!; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-input.json new file mode 100644 index 000000000..0a3070217 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-input.json @@ -0,0 +1,62 @@ +{ + "id": "child-with-input-v1", + "definitionId": "child-with-input", + "name": "Child With Input", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [ + { + "uiHint": "singleline", + "storageDriverType": "Elsa.Workflows.Services.WorkflowStorageDriver, Elsa.Workflows.Core", + "type": "String", + "name": "Message", + "displayName": "Message", + "description": "", + "category": "", + "isArray": false + } + ], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "Flowchart1", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "start": "WriteLine1", + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "JavaScript", + "value": "`Child received: ${getMessage()}`" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-output.json new file mode 100644 index 000000000..320a0198e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-with-output.json @@ -0,0 +1,100 @@ +{ + "id": "child-with-output-v1", + "definitionId": "child-with-output", + "name": "Child With Output", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [ + { + "type": "Object", + "name": "Result", + "displayName": "Result", + "description": "", + "isArray": false + } + ], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "Flowchart1", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "start": "WriteLine1", + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Child output: 42" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "outputName": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Result" + }, + "memoryReference": { + "id": "SetOutput1:outputName" + } + }, + "outputValue": { + "typeName": "Object", + "expression": { + "type": "Literal", + "value": 42 + }, + "memoryReference": { + "id": "SetOutput1:outputValue" + } + }, + "id": "SetOutput1", + "type": "Elsa.SetOutput", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [ + { + "source": { + "activity": "WriteLine1", + "port": "Done" + }, + "target": { + "activity": "SetOutput1", + "port": "In" + } + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-workflow.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-workflow.json new file mode 100644 index 000000000..644918eb1 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/child-workflow.json @@ -0,0 +1,51 @@ +{ + "id": "child-workflow-v1", + "definitionId": "child-workflow", + "name": "Child Workflow", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "Flowchart1", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "start": "WriteLine1", + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Child: Executing" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-capture-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-capture-output.json new file mode 100644 index 000000000..1dd0599cf --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-capture-output.json @@ -0,0 +1,92 @@ +{ + "id": "parent-capture-output-v1", + "definitionId": "parent-capture-output", + "name": "Parent Capture Output", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [ + { + "id": "ChildResult", + "name": "ChildResult", + "typeName": "Object", + "isArray": false, + "value": null, + "storageDriverType": "Elsa.Workflows.Services.WorkflowStorageDriver, Elsa.Workflows.Core" + } + ], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Sequence", + "version": 1, + "id": "Sequence1", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "workflowDefinitionId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "child-with-output" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:workflowDefinitionId" + } + }, + "waitForCompletion": { + "typeName": "Boolean", + "expression": { + "type": "Literal", + "value": true + }, + "memoryReference": { + "id": "ExecuteWorkflow1:waitForCompletion" + } + }, + "result": { + "typeName": "ExecuteWorkflowResult", + "memoryReference": { + "id": "ChildResult" + } + }, + "id": "ExecuteWorkflow1", + "type": "Elsa.ExecuteWorkflow", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "JavaScript", + "value": "`Parent received: ${getChildResult().output.Result}`" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-no-wait.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-no-wait.json new file mode 100644 index 000000000..883e91e39 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-no-wait.json @@ -0,0 +1,97 @@ +{ + "id": "parent-no-wait-v1", + "definitionId": "parent-no-wait", + "name": "Parent No Wait", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Sequence", + "version": 1, + "id": "Sequence1", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Parent: Before child" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "workflowDefinitionId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "child-workflow" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:workflowDefinitionId" + } + }, + "waitForCompletion": { + "typeName": "Boolean", + "expression": { + "type": "Literal", + "value": false + }, + "memoryReference": { + "id": "ExecuteWorkflow1:waitForCompletion" + } + }, + "id": "ExecuteWorkflow1", + "type": "Elsa.ExecuteWorkflow", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Parent: After child" + }, + "memoryReference": { + "id": "WriteLine2:input-1" + } + }, + "id": "WriteLine2", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-correlation.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-correlation.json new file mode 100644 index 000000000..8bd01c75e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-correlation.json @@ -0,0 +1,71 @@ +{ + "id": "parent-with-correlation-v1", + "definitionId": "parent-with-correlation", + "name": "Parent With Correlation", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "Flowchart1", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "start": "ExecuteWorkflow1", + "activities": [ + { + "workflowDefinitionId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "child-workflow" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:workflowDefinitionId" + } + }, + "correlationId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "test-correlation-123" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:correlationId" + } + }, + "waitForCompletion": { + "typeName": "Boolean", + "expression": { + "type": "Literal", + "value": true + }, + "memoryReference": { + "id": "ExecuteWorkflow1:waitForCompletion" + } + }, + "id": "ExecuteWorkflow1", + "type": "Elsa.ExecuteWorkflow", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-input.json new file mode 100644 index 000000000..8866c3469 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-input.json @@ -0,0 +1,71 @@ +{ + "id": "parent-with-input-v1", + "definitionId": "parent-with-input", + "name": "Parent With Input", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "Flowchart1", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:45", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "start": "ExecuteWorkflow1", + "activities": [ + { + "workflowDefinitionId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "child-with-input" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:workflowDefinitionId" + } + }, + "input": { + "typeName": "IDictionary", + "expression": { + "type": "JavaScript", + "value": "({ Message: 'Hello from parent' })" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:input" + } + }, + "waitForCompletion": { + "typeName": "Boolean", + "expression": { + "type": "Literal", + "value": true + }, + "memoryReference": { + "id": "ExecuteWorkflow1:waitForCompletion" + } + }, + "id": "ExecuteWorkflow1", + "type": "Elsa.ExecuteWorkflow", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ], + "connections": [] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-wait.json b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-wait.json new file mode 100644 index 000000000..785a236a3 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ExecuteWorkflow/Workflows/parent-with-wait.json @@ -0,0 +1,97 @@ +{ + "id": "parent-with-wait-v1", + "definitionId": "parent-with-wait", + "name": "Parent With Wait", + "createdAt": "2025-01-15T00:00:00.000000+00:00", + "version": 1, + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": true, + "root": { + "type": "Elsa.Sequence", + "version": 1, + "id": "Sequence1", + "metadata": {}, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Parent: Before child" + }, + "memoryReference": { + "id": "WriteLine1:input-1" + } + }, + "id": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "workflowDefinitionId": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "child-workflow" + }, + "memoryReference": { + "id": "ExecuteWorkflow1:workflowDefinitionId" + } + }, + "waitForCompletion": { + "typeName": "Boolean", + "expression": { + "type": "Literal", + "value": true + }, + "memoryReference": { + "id": "ExecuteWorkflow1:waitForCompletion" + } + }, + "id": "ExecuteWorkflow1", + "type": "Elsa.ExecuteWorkflow", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Parent: After child" + }, + "memoryReference": { + "id": "WriteLine2:input-1" + } + }, + "id": "WriteLine2", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index 08c0420f9..6edd6fa0d 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -62,5 +62,29 @@ Always + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs new file mode 100644 index 000000000..1dd0a993c --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs @@ -0,0 +1,268 @@ +using Elsa.Common.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Options; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.State; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Composition; + +public class ExecuteWorkflowTests +{ + private const string DefaultWorkflowDefinitionId = "test-workflow-def"; + private const string DefaultGeneratedId = "generated-child-id"; + private const string DefaultCorrelationId = "test-correlation"; + + [Theory] + [InlineData(true, WorkflowStatus.Finished)] + [InlineData(true, WorkflowStatus.Running)] + [InlineData(false, WorkflowStatus.Finished)] + [InlineData(false, WorkflowStatus.Running)] + public async Task Should_Invoke_Workflow_With_Correct_Options(bool waitForCompletion, WorkflowStatus childWorkflowStatus) + { + // Arrange + var input = new Dictionary { ["Key1"] = "Value1" }; + var childOutput = new Dictionary { ["ChildKey"] = "ChildValue" }; + + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId), + CorrelationId = new(DefaultCorrelationId), + Input = new(input), + WaitForCompletion = new(waitForCompletion) + }; + + // Act + var (context, workflowInvoker) = await ExecuteAsync(executeWorkflow, childWorkflowStatus, childOutput); + var parentInstanceId = context.WorkflowExecutionContext.Id; + + // Assert + await workflowInvoker.Received(1).InvokeAsync( + Arg.Any(), + Arg.Is(opts => + opts.ParentWorkflowInstanceId == parentInstanceId && + opts.WorkflowInstanceId == DefaultGeneratedId && + opts.CorrelationId == DefaultCorrelationId && + opts.Input != null && opts.Input.ContainsKey("Key1") && (string)opts.Input["Key1"] == "Value1" && + opts.Input.ContainsKey("ParentInstanceId") && (string)opts.Input["ParentInstanceId"] == parentInstanceId && + opts.Properties.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId && + (waitForCompletion ? opts.Properties.ContainsKey("WaitForCompletion") && (bool)opts.Properties["WaitForCompletion"] : !opts.Properties.ContainsKey("WaitForCompletion")) + ), + Arg.Any() + ); + + // Result is only set when either not waiting or child workflow finishes + if (!waitForCompletion || childWorkflowStatus == WorkflowStatus.Finished) + { + var result = (ExecuteWorkflowResult)context.GetActivityOutput(() => executeWorkflow.Result)!; + Assert.NotNull(result); + Assert.Equal(DefaultGeneratedId, result.WorkflowInstanceId); + Assert.Equal(childWorkflowStatus, result.Status); + Assert.Equal(childOutput, result.Output); + } + } + + [Theory] + [InlineData(false, WorkflowStatus.Running)] + [InlineData(true, WorkflowStatus.Finished)] + public async Task Should_Complete_Activity_When_Expected(bool waitForCompletion, WorkflowStatus childWorkflowStatus) + { + // Arrange + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId), + WaitForCompletion = new(waitForCompletion) + }; + + // Act + var (context, _) = await ExecuteAsync(executeWorkflow, childWorkflowStatus); + + // Assert - Activity should complete when not waiting or when child workflow finishes + Assert.True(context.IsCompleted); + } + + [Fact] + public async Task Should_Create_Bookmark_When_WaitForCompletion_Is_True_And_Child_Is_Running() + { + // Arrange + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId), + WaitForCompletion = new(true) + }; + + // Act + var (context, _) = await ExecuteAsync(executeWorkflow, WorkflowStatus.Running); + + // Assert - Activity should not complete when child workflow is still running and waiting + Assert.False(context.IsCompleted); + Assert.NotEmpty(context.Bookmarks); + } + + [Fact] + public async Task Should_Throw_When_Workflow_Definition_Not_Found() + { + // Arrange + const string workflowDefinitionId = "non-existent-workflow"; + + var workflowDefinitionService = Substitute.For(); + workflowDefinitionService + .FindWorkflowGraphAsync(workflowDefinitionId, Arg.Any(), Arg.Any()) + .Returns((WorkflowGraph?)null); + + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(workflowDefinitionId) + }; + + // Act & Assert + await Assert.ThrowsAsync(() => ExecuteAsync(executeWorkflow, customWorkflowDefinitionService: workflowDefinitionService)); + } + + [Fact] + public async Task Should_Use_Empty_Input_When_Input_Not_Provided() + { + // Arrange + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId) + }; + + // Act + var (_, workflowInvoker) = await ExecuteAsync(executeWorkflow); + + // Assert - Should create input with ParentInstanceId even when no input provided + await workflowInvoker.Received(1).InvokeAsync( + Arg.Any(), + Arg.Is(opts => + opts.Input != null && + opts.Input.ContainsKey("ParentInstanceId") && + opts.Input.Count == 1 + ), + Arg.Any() + ); + } + + [Fact] + public async Task Should_Use_Null_CorrelationId_When_Not_Provided() + { + // Arrange + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId) + }; + + // Act + var (_, workflowInvoker) = await ExecuteAsync(executeWorkflow); + + // Assert + await workflowInvoker.Received(1).InvokeAsync( + Arg.Any(), + Arg.Is(opts => opts.CorrelationId == null), + Arg.Any() + ); + } + + [Theory] + [InlineData(WorkflowSubStatus.Cancelled)] + [InlineData(WorkflowSubStatus.Faulted)] + [InlineData(WorkflowSubStatus.Suspended)] + public async Task Should_Handle_Different_Workflow_SubStatuses(WorkflowSubStatus subStatus) + { + // Arrange + var executeWorkflow = new ExecuteWorkflow + { + WorkflowDefinitionId = new(DefaultWorkflowDefinitionId), + WaitForCompletion = new(false) + }; + + // Act + var (context, _) = await ExecuteAsync(executeWorkflow, WorkflowStatus.Finished, null, subStatus); + + // Assert + var result = (ExecuteWorkflowResult)context.GetActivityOutput(() => executeWorkflow.Result)!; + Assert.NotNull(result); + Assert.Equal(subStatus, result.SubStatus); + } + + private static WorkflowGraph CreateMockWorkflowGraph() + { + var writeLine = new WriteLine("Test") { Id = "test-activity" }; + var workflow = Workflow.FromActivity(writeLine); + workflow.Id = "test-workflow"; + var rootNode = new ActivityNode(writeLine, "Done"); + return new(workflow, rootNode, [rootNode]); + } + + private static IWorkflowDefinitionService CreateWorkflowDefinitionService(string workflowDefinitionId, WorkflowGraph workflowGraph) + { + var service = Substitute.For(); + service + .FindWorkflowGraphAsync(workflowDefinitionId, Arg.Any(), Arg.Any()) + .Returns(workflowGraph); + return service; + } + + private static IIdentityGenerator CreateIdentityGenerator(string generatedId) + { + var generator = Substitute.For(); + generator.GenerateId().Returns(generatedId); + return generator; + } + + private static IWorkflowInvoker CreateWorkflowInvoker( + WorkflowStatus status, + IDictionary? output, + WorkflowSubStatus subStatus = WorkflowSubStatus.Executing) + { + var invoker = Substitute.For(); + var workflow = Workflow.FromActivity(new WriteLine("Test")); + var workflowState = new WorkflowState + { + Status = status, + SubStatus = subStatus, + Output = output + }; + var workflowResult = new RunWorkflowResult(null!, workflowState, workflow, null, Journal.Empty); + + invoker + .InvokeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(workflowResult); + + return invoker; + } + + private static async Task<(ActivityExecutionContext Context, IWorkflowInvoker WorkflowInvoker)> + ExecuteAsync( + ExecuteWorkflow executeWorkflow, + WorkflowStatus childWorkflowStatus = WorkflowStatus.Finished, + IDictionary? output = null, + WorkflowSubStatus subStatus = WorkflowSubStatus.Executing, + IWorkflowDefinitionService? customWorkflowDefinitionService = null, + IWorkflowInvoker? customWorkflowInvoker = null, + IIdentityGenerator? customIdentityGenerator = null) + { + var workflowGraph = CreateMockWorkflowGraph(); + var workflowDefinitionService = customWorkflowDefinitionService ?? CreateWorkflowDefinitionService(DefaultWorkflowDefinitionId, workflowGraph); + var identityGenerator = customIdentityGenerator ?? CreateIdentityGenerator(DefaultGeneratedId); + var workflowInvoker = customWorkflowInvoker ?? CreateWorkflowInvoker(childWorkflowStatus, output, subStatus); + + var context = await new ActivityTestFixture(executeWorkflow) + .ConfigureServices(services => + { + services.AddSingleton(workflowDefinitionService); + services.AddSingleton(workflowInvoker); + services.AddSingleton(identityGenerator); + services.AddSingleton(_ => Substitute.For()); + }) + .ExecuteAsync(); + + return (context, workflowInvoker); + } + +}