826 lines
30 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|