namespace Workflow.Tests.Engine; using FluentAssertions; using Moq; using Workflow.Application.Engine; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Workflow.Domain.Exceptions; using Workflow.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Xunit; public class ProcessEngineTests { private readonly Mock _serviceProvider; private readonly ProcessEngine _engine; private readonly WorkflowDbContext _dbContext; public ProcessEngineTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; _dbContext = new WorkflowDbContext(options); _serviceProvider = new Mock(); _engine = new ProcessEngine(_dbContext, _serviceProvider.Object); } // ============================================================ // Start Node // ============================================================ [Fact] public async Task ProcessStartNode_CreatesSingleToken_AndPropagatesToNextNode() { var startNode = CreateNode(NodeType.Start, "start-1"); var nextNode = CreateNode(NodeType.Approval, "approval-1"); var edge = CreateEdge(startNode, nextNode); var definition = CreateDefinition(startNode, nextNode, edge); var instance = CreateInstance(definition); await _engine.StartAsync(instance); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(nextNode.Id); tokens[0].Status.Should().Be(TokenStatus.Active); instance.Status.Should().Be(InstanceStatus.Running); } [Fact] public async Task ProcessStartNode_ThrowsWhenNoOutgoingEdge() { var startNode = CreateNode(NodeType.Start, "start-1"); var definition = CreateDefinition(startNode); var instance = CreateInstance(definition); var act = () => _engine.StartAsync(instance); await act.Should().ThrowAsync() .WithMessage("*start*edge*"); } // ============================================================ // Approval Node // ============================================================ [Fact] public async Task ProcessApprovalNode_CreatesTaskForAssignee() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); approvalNode.Config = """{ "assigneeRule": "user:user-001", "formId": "form-001" }"""; var definition = CreateDefinition(approvalNode); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); await _engine.ProcessNodeAsync(instance, token, approvalNode); var tasks = await _dbContext.WorkflowTasks .Where(t => t.InstanceId == instance.Id) .ToListAsync(); tasks.Should().HaveCount(1); tasks[0].AssigneeRole.Should().Be("user-001"); tasks[0].Type.Should().Be(TaskType.Approval); tasks[0].Status.Should().Be(TaskStatus.Pending); } [Fact] public async Task ProcessApprovalNode_CreatesTaskForRole() { var approvalNode = CreateNode(NodeType.Approval, "approval-2"); approvalNode.Config = """{ "assigneeRule": "role:manager", "formId": "form-001" }"""; var definition = CreateDefinition(approvalNode); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); await _engine.ProcessNodeAsync(instance, token, approvalNode); var tasks = await _dbContext.WorkflowTasks .Where(t => t.InstanceId == instance.Id) .ToListAsync(); tasks.Should().HaveCount(1); tasks[0].AssigneeRole.Should().Be("manager"); tasks[0].Type.Should().Be(TaskType.Approval); tasks[0].Status.Should().Be(TaskStatus.Pending); } [Fact] public async Task CompleteTask_Approved_PropagatesTokenAlongApprovedEdge() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var approvedEdge = CreateEdge(approvalNode, nextNode, EdgeType.Approved); approvalNode.Config = """{ "assigneeRule": "user:user-001" }"""; var definition = CreateDefinition(approvalNode, nextNode, approvedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id, AssigneeRole = "user-001", Type = TaskType.Approval, Status = TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); await _engine.CompleteTaskAsync(task, TaskResult.Approved); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(nextNode.Id); } [Fact] public async Task CompleteTask_Rejected_PropagatesTokenAlongRejectedEdge() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var rejectNode = CreateNode(NodeType.End, "reject-end-1"); var rejectedEdge = CreateEdge(approvalNode, rejectNode, EdgeType.Rejected); approvalNode.Config = """{ "assigneeRule": "user:user-001" }"""; var definition = CreateDefinition(approvalNode, rejectNode, rejectedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id, AssigneeRole = "user-001", Type = TaskType.Approval, Status = TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); await _engine.CompleteTaskAsync(task, TaskResult.Rejected); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(rejectNode.Id); } [Fact] public async Task CompleteTask_Rejected_WithNoRejectEdge_ThrowsBusinessException() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var approvedEdge = CreateEdge(approvalNode, nextNode, EdgeType.Approved); approvalNode.Config = """{ "assigneeRule": "user:user-001" }"""; var definition = CreateDefinition(approvalNode, nextNode, approvedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id, AssigneeRole = "user-001", Type = TaskType.Approval, Status = TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); var act = () => _engine.CompleteTaskAsync(task, TaskResult.Rejected); await act.Should().ThrowAsync() .WithMessage("*reject*edge*"); } // ============================================================ // Cc Node // ============================================================ [Fact] public async Task ProcessCcNode_CreatesCcTasks_ForAllRecipients() { var ccNode = CreateNode(NodeType.Cc, "cc-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var edge = CreateEdge(ccNode, nextNode); ccNode.Config = """{ "recipients": ["user-001", "user-002", "user-003"] }"""; var definition = CreateDefinition(ccNode, nextNode, edge); var instance = CreateInstance(definition); var token = CreateToken(instance, ccNode); await _engine.ProcessNodeAsync(instance, token, ccNode); var tasks = await _dbContext.WorkflowTasks .Where(t => t.InstanceId == instance.Id) .ToListAsync(); tasks.Should().HaveCount(3); tasks.Should().OnlyContain(t => t.Type == TaskType.Cc); tasks.Should().OnlyContain(t => t.Status == Enums.TaskStatus.Pending); tasks.Select(t => t.AssigneeId).Should().BeEquivalentTo("user-001", "user-002", "user-003"); } [Fact] public async Task ProcessCcNode_PropagatesImmediatelyWithoutWaiting() { var ccNode = CreateNode(NodeType.Cc, "cc-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var edge = CreateEdge(ccNode, nextNode); ccNode.Config = """{ "recipients": ["user-001"] }"""; var definition = CreateDefinition(ccNode, nextNode, edge); var instance = CreateInstance(definition); var token = CreateToken(instance, ccNode); await _engine.ProcessNodeAsync(instance, token, ccNode); var propagatedTokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.NodeId == nextNode.Id && t.Status == TokenStatus.Active) .ToListAsync(); propagatedTokens.Should().HaveCount(1); var ccTasks = await _dbContext.WorkflowTasks .Where(t => t.InstanceId == instance.Id && t.Type == TaskType.Cc) .ToListAsync(); ccTasks.Should().HaveCount(1); ccTasks[0].Status.Should().Be(Enums.TaskStatus.Pending); } // ============================================================ // Condition Gateway (Exclusive Gateway) // ============================================================ [Fact] public async Task ProcessConditionNode_TakesMatchingBranch() { var conditionNode = CreateNode(NodeType.Condition, "gw-1"); var branchA = CreateNode(NodeType.End, "branch-a"); var branchB = CreateNode(NodeType.End, "branch-b"); var edgeA = CreateEdge(conditionNode, branchA, condition: "amount > 1000"); var edgeB = CreateEdge(conditionNode, branchB, condition: "amount <= 1000"); var definition = CreateDefinition(conditionNode, branchA, branchB, edgeA, edgeB); var instance = CreateInstance(definition); instance.Variables = """{ "amount": 1500 }"""; var token = CreateToken(instance, conditionNode); await _engine.ProcessNodeAsync(instance, token, conditionNode); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(branchA.Id); } [Fact] public async Task ProcessConditionNode_NoMatchingBranch_ThrowsBusinessException() { var conditionNode = CreateNode(NodeType.Condition, "gw-1"); var branchA = CreateNode(NodeType.End, "branch-a"); var edgeA = CreateEdge(conditionNode, branchA, condition: "amount > 5000"); var definition = CreateDefinition(conditionNode, branchA, edgeA); var instance = CreateInstance(definition); instance.Variables = """{ "amount": 100 }"""; var token = CreateToken(instance, conditionNode); var act = () => _engine.ProcessNodeAsync(instance, token, conditionNode); await act.Should().ThrowAsync() .WithMessage("*condition*match*"); } [Fact] public async Task ProcessConditionNode_FirstMatchingBranchWins_WhenMultipleMatch() { var conditionNode = CreateNode(NodeType.Condition, "gw-1"); var branchA = CreateNode(NodeType.End, "branch-a"); var branchB = CreateNode(NodeType.End, "branch-b"); var branchC = CreateNode(NodeType.End, "branch-c"); var edgeA = CreateEdge(conditionNode, branchA, condition: "amount > 1000", order: 1); var edgeB = CreateEdge(conditionNode, branchB, condition: "amount > 500", order: 2); var edgeC = CreateEdge(conditionNode, branchC, condition: "amount > 2000", order: 3); var definition = CreateDefinition(conditionNode, branchA, branchB, branchC, edgeA, edgeB, edgeC); var instance = CreateInstance(definition); instance.Variables = """{ "amount": 1500 }"""; var token = CreateToken(instance, conditionNode); await _engine.ProcessNodeAsync(instance, token, conditionNode); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(branchA.Id); } // ============================================================ // Parallel Gateway (Fork) // ============================================================ [Fact] public async Task ProcessParallelFork_CreatesOneTokenPerOutgoingEdge() { var forkNode = CreateNode(NodeType.Parallel, "fork-1"); var branchA = CreateNode(NodeType.Approval, "branch-a"); var branchB = CreateNode(NodeType.Approval, "branch-b"); var edgeA = CreateEdge(forkNode, branchA); var edgeB = CreateEdge(forkNode, branchB); var definition = CreateDefinition(forkNode, branchA, branchB, edgeA, edgeB); var instance = CreateInstance(definition); var token = CreateToken(instance, forkNode); await _engine.ProcessNodeAsync(instance, token, forkNode); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(2); tokens.Select(t => t.NodeId).Should().BeEquivalentTo(branchA.Id, branchB.Id); } [Fact] public async Task ProcessParallelFork_With3Edges_Creates3Tokens() { var forkNode = CreateNode(NodeType.Parallel, "fork-1"); var branchA = CreateNode(NodeType.Approval, "branch-a"); var branchB = CreateNode(NodeType.Approval, "branch-b"); var branchC = CreateNode(NodeType.Approval, "branch-c"); var edgeA = CreateEdge(forkNode, branchA); var edgeB = CreateEdge(forkNode, branchB); var edgeC = CreateEdge(forkNode, branchC); var definition = CreateDefinition(forkNode, branchA, branchB, branchC, edgeA, edgeB, edgeC); var instance = CreateInstance(definition); var token = CreateToken(instance, forkNode); await _engine.ProcessNodeAsync(instance, token, forkNode); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(3); tokens.Select(t => t.NodeId) .Should().BeEquivalentTo(branchA.Id, branchB.Id, branchC.Id); } // ============================================================ // Parallel Gateway (Join) // ============================================================ [Fact] public async Task ProcessParallelJoin_WaitsForAllTokens_WhenNotAllArrived() { var joinNode = CreateNode(NodeType.Parallel, "join-1"); var branchA = CreateNode(NodeType.Approval, "branch-a"); var branchB = CreateNode(NodeType.Approval, "branch-b"); var branchC = CreateNode(NodeType.Approval, "branch-c"); var edgeA = CreateEdge(branchA, joinNode); var edgeB = CreateEdge(branchB, joinNode); var edgeC = CreateEdge(branchC, joinNode); var definition = CreateDefinition(joinNode, branchA, branchB, branchC, edgeA, edgeB, edgeC); var instance = CreateInstance(definition); var token1 = CreateToken(instance, joinNode, TokenStatus.Active); var token2 = CreateToken(instance, joinNode, TokenStatus.Active); await _engine.ProcessNodeAsync(instance, token2, joinNode); var activeTokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); activeTokens.Should().HaveCount(2); activeTokens.Should().OnlyContain(t => t.NodeId == joinNode.Id); } [Fact] public async Task ProcessParallelJoin_MergesAllTokens_WhenAllArrived() { var joinNode = CreateNode(NodeType.Parallel, "join-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var branchA = CreateNode(NodeType.Approval, "branch-a"); var branchB = CreateNode(NodeType.Approval, "branch-b"); var edgeA = CreateEdge(branchA, joinNode); var edgeB = CreateEdge(branchB, joinNode); var edgeOut = CreateEdge(joinNode, nextNode); var definition = CreateDefinition( joinNode, nextNode, branchA, branchB, edgeA, edgeB, edgeOut); var instance = CreateInstance(definition); var token1 = CreateToken(instance, joinNode, TokenStatus.Active); var token2 = CreateToken(instance, joinNode, TokenStatus.Active); await _engine.ProcessNodeAsync(instance, token2, joinNode); var consumedTokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Consumed) .ToListAsync(); consumedTokens.Should().HaveCount(2); var activeTokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); activeTokens.Should().HaveCount(1); activeTokens[0].NodeId.Should().Be(nextNode.Id); } // ============================================================ // End Node // ============================================================ [Fact] public async Task ProcessEndNode_ConsumesToken() { var endNode = CreateNode(NodeType.End, "end-1"); var definition = CreateDefinition(endNode); var instance = CreateInstance(definition); var token = CreateToken(instance, endNode); await _engine.ProcessNodeAsync(instance, token, endNode); token.Status.Should().Be(TokenStatus.Consumed); } [Fact] public async Task ProcessEndNode_CompletesInstance_WhenAllTokensConsumed() { var endNode = CreateNode(NodeType.End, "end-1"); var definition = CreateDefinition(endNode); var instance = CreateInstance(definition, InstanceStatus.Running); var token = CreateToken(instance, endNode); await _engine.ProcessNodeAsync(instance, token, endNode); instance.Status.Should().Be(InstanceStatus.Completed); } [Fact] public async Task ProcessEndNode_DoesNotCompleteInstance_WhenOtherTokensActive() { var endNode = CreateNode(NodeType.End, "end-1"); var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var definition = CreateDefinition(endNode, approvalNode); var instance = CreateInstance(definition, InstanceStatus.Running); var endToken = CreateToken(instance, endNode, TokenStatus.Active); var activeToken = CreateToken(instance, approvalNode, TokenStatus.Active); await _engine.ProcessNodeAsync(instance, endToken, endNode); endToken.Status.Should().Be(TokenStatus.Consumed); activeToken.Status.Should().Be(TokenStatus.Active); instance.Status.Should().Be(InstanceStatus.Running); } // ============================================================ // SubProcess // ============================================================ [Fact] public async Task ProcessSubProcessNode_CreatesChildInstance() { var subDefId = Guid.NewGuid(); var subProcessNode = CreateNode(NodeType.SubProcess, "sub-1"); subProcessNode.Config = $"{{ \"definitionId\": \"{subDefId}\" }}"; var definition = CreateDefinition(subProcessNode); var instance = CreateInstance(definition); var token = CreateToken(instance, subProcessNode); await _engine.ProcessNodeAsync(instance, token, subProcessNode); var childInstance = await _dbContext.WorkflowInstances .FirstOrDefaultAsync(i => i.ParentInstanceId == instance.Id); childInstance.Should().NotBeNull(); childInstance!.DefinitionId.Should().Be(subDefId); childInstance.Status.Should().Be(InstanceStatus.Running); } [Fact] public async Task SubProcessComplete_PropagatesTokenInParent() { var subProcessNode = CreateNode(NodeType.SubProcess, "sub-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var edge = CreateEdge(subProcessNode, nextNode); subProcessNode.Config = """{ "definitionId": "00000000-0000-0000-0000-000000000001" }"""; var definition = CreateDefinition(subProcessNode, nextNode, edge); var parentInstance = CreateInstance(definition); var childInstance = new WorkflowInstance { Id = Guid.NewGuid(), DefinitionId = Guid.Parse("00000000-0000-0000-0000-000000000001"), ParentInstanceId = parentInstance.Id, ParentTokenId = Guid.Empty, Status = InstanceStatus.Running, }; _dbContext.WorkflowInstances.Add(childInstance); await _dbContext.SaveChangesAsync(); childInstance.Status = InstanceStatus.Completed; await _dbContext.SaveChangesAsync(); await _engine.HandleSubProcessCompletionAsync(childInstance); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == parentInstance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(nextNode.Id); } // ============================================================ // Node Actions (Hooks) // ============================================================ [Fact] public async Task ProcessApprovalNode_ExecutesOnEnterAction() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); approvalNode.Config = """{ "assigneeRule": "user:user-001", "onEnter": "send-notification" }"""; var definition = CreateDefinition(approvalNode); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var actionExecuted = false; _serviceProvider .Setup(sp => sp.GetService(It.IsAny())) .Returns(new TestAction(() => actionExecuted = true)); await _engine.ProcessNodeAsync(instance, token, approvalNode); actionExecuted.Should().BeTrue("the onEnter action should be executed when entering an approval node"); } [Fact] public async Task CompleteTask_Approved_ExecutesOnApprovedAction() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var approvedEdge = CreateEdge(approvalNode, nextNode, EdgeType.Approved); approvalNode.Config = """{ "assigneeRule": "user:user-001", "onApproved": "log-approval" }"""; var definition = CreateDefinition(approvalNode, nextNode, approvedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id.ToString(), AssigneeId = "user-001", Type = TaskType.Approval, Status = Enums.TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); var actionExecuted = false; _serviceProvider .Setup(sp => sp.GetService(It.IsAny())) .Returns(new TestAction(() => actionExecuted = true)); await _engine.CompleteTaskAsync(task, TaskResult.Approved); actionExecuted.Should().BeTrue("the onApproved action should be executed when task is approved"); } [Fact] public async Task CompleteTask_Rejected_ExecutesOnRejectedAction() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var rejectNode = CreateNode(NodeType.End, "reject-end-1"); var rejectedEdge = CreateEdge(approvalNode, rejectNode, EdgeType.Rejected); approvalNode.Config = """{ "assigneeRule": "user:user-001", "onRejected": "notify-rejection" }"""; var definition = CreateDefinition(approvalNode, rejectNode, rejectedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id.ToString(), AssigneeId = "user-001", Type = TaskType.Approval, Status = Enums.TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); var actionExecuted = false; _serviceProvider .Setup(sp => sp.GetService(It.IsAny())) .Returns(new TestAction(() => actionExecuted = true)); await _engine.CompleteTaskAsync(task, TaskResult.Rejected); actionExecuted.Should().BeTrue("the onRejected action should be executed when task is rejected"); } [Fact] public async Task ActionFailure_DoesNotBlockTokenPropagation() { var approvalNode = CreateNode(NodeType.Approval, "approval-1"); var nextNode = CreateNode(NodeType.End, "end-1"); var approvedEdge = CreateEdge(approvalNode, nextNode, EdgeType.Approved); approvalNode.Config = """{ "assigneeRule": "user:user-001", "onApproved": "failing-action" }"""; var definition = CreateDefinition(approvalNode, nextNode, approvedEdge); var instance = CreateInstance(definition); var token = CreateToken(instance, approvalNode); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instance.Id, TokenId = token.Id, NodeId = approvalNode.Id.ToString(), AssigneeId = "user-001", Type = TaskType.Approval, Status = Enums.TaskStatus.Pending, }; _dbContext.WorkflowTasks.Add(task); await _dbContext.SaveChangesAsync(); _serviceProvider .Setup(sp => sp.GetService(It.IsAny())) .Returns(new ThrowingAction()); await _engine.CompleteTaskAsync(task, TaskResult.Approved); var tokens = await _dbContext.WorkflowTokens .Where(t => t.InstanceId == instance.Id && t.Status == TokenStatus.Active) .ToListAsync(); tokens.Should().HaveCount(1); tokens[0].NodeId.Should().Be(nextNode.Id); } // ============================================================ // Test Helpers // ============================================================ private static WorkflowNode CreateNode(NodeType type, string nodeId) { return new WorkflowNode { Id = Guid.NewGuid(), NodeType = type, Config = "{}", }; } private static WorkflowEdge CreateEdge( WorkflowNode source, WorkflowNode target, EdgeType? edgeType = null, string? condition = null, int order = 0) { return new WorkflowEdge { Id = Guid.NewGuid(), SourceNodeId = source.Id.ToString(), TargetNodeId = target.Id.ToString(), EdgeType = edgeType ?? EdgeType.Normal, Condition = condition, Order = order, }; } private static WorkflowDefinition CreateDefinition( params object[] elements) { var nodes = elements.OfType().ToList(); var edges = elements.OfType().ToList(); return new WorkflowDefinition { Id = Guid.NewGuid(), Nodes = nodes, Edges = edges, }; } private static WorkflowInstance CreateInstance( WorkflowDefinition definition, InstanceStatus status = InstanceStatus.Pending) { return new WorkflowInstance { Id = Guid.NewGuid(), DefinitionId = definition.Id, Status = status, Variables = "{}", }; } private WorkflowToken CreateToken( WorkflowInstance instance, WorkflowNode node, TokenStatus status = TokenStatus.Active) { var token = new WorkflowToken { Id = Guid.NewGuid(), InstanceId = instance.Id, NodeId = node.Id, Status = status, }; _dbContext.WorkflowTokens.Add(token); _dbContext.SaveChanges(); return token; } private class TestAction : INodeAction { private readonly Action _callback; public TestAction(Action callback) { _callback = callback; } public Task ExecuteAsync(NodeActionContext context) { _callback(); return Task.CompletedTask; } } private class ThrowingAction : INodeAction { public Task ExecuteAsync(NodeActionContext context) { throw new InvalidOperationException("Simulated action failure"); } } }