work-flow/tests/Workflow.Tests/Engine/ProcessEngineTests.cs
2026-05-17 22:46:19 +08:00

826 lines
30 KiB
C#

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;
public class ProcessEngineTests
{
private readonly Mock<IServiceProvider> _serviceProvider;
private readonly ProcessEngine _engine;
private readonly WorkflowDbContext _dbContext;
public ProcessEngineTests()
{
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_dbContext = new WorkflowDbContext(options);
_serviceProvider = new Mock<IServiceProvider>();
_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<BusinessException>()
.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].AssigneeId.Should().Be("user-001");
tasks[0].Type.Should().Be(TaskType.Approval);
tasks[0].Status.Should().Be(Enums.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(Enums.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.ToString(),
AssigneeId = "user-001",
Type = TaskType.Approval,
Status = Enums.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.ToString(),
AssigneeId = "user-001",
Type = TaskType.Approval,
Status = Enums.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.ToString(),
AssigneeId = "user-001",
Type = TaskType.Approval,
Status = Enums.TaskStatus.Pending,
};
_dbContext.WorkflowTasks.Add(task);
await _dbContext.SaveChangesAsync();
var act = () => _engine.CompleteTaskAsync(task, TaskResult.Rejected);
await act.Should().ThrowAsync<BusinessException>()
.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<BusinessException>()
.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<Type>()))
.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<Type>()))
.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<Type>()))
.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<Type>()))
.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<WorkflowNode>().ToList();
var edges = elements.OfType<WorkflowEdge>().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");
}
}
}