520 lines
16 KiB
C#
520 lines
16 KiB
C#
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Workflow.Application.Engine;
|
|
using Workflow.Application.Features.WorkflowInstances.Commands;
|
|
using Workflow.Application.Features.WorkflowInstances.Queries;
|
|
using Workflow.Domain.Entities;
|
|
using Workflow.Domain.Enums;
|
|
using Workflow.Domain.Exceptions;
|
|
using Workflow.Infrastructure.Persistence;
|
|
using Xunit;
|
|
|
|
using TaskStatus = Workflow.Domain.Enums.TaskStatus;
|
|
|
|
namespace Workflow.Tests.Handlers;
|
|
|
|
public class WorkflowInstanceHandlerTests
|
|
{
|
|
private static WorkflowDbContext CreateDbContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new WorkflowDbContext(options);
|
|
}
|
|
|
|
private static async Task<WorkflowDbContext> SeedPublishedDefinitionAsync(
|
|
string code = "leave-approval",
|
|
string name = "Leave Approval",
|
|
Guid? startNodeId = null)
|
|
{
|
|
var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
var nodeId = startNodeId ?? Guid.NewGuid();
|
|
var approvalNodeId = Guid.NewGuid();
|
|
|
|
var definition = new WorkflowDefinition
|
|
{
|
|
Id = definitionId,
|
|
Name = name,
|
|
Code = code,
|
|
Status = DefinitionStatus.Published,
|
|
Version = 2,
|
|
IsEnabled = true
|
|
};
|
|
db.WorkflowDefinitions.Add(definition);
|
|
|
|
var startNode = new WorkflowNode
|
|
{
|
|
Id = nodeId,
|
|
DefinitionId = definitionId,
|
|
NodeType = NodeType.Start,
|
|
Name = "Start",
|
|
PositionX = 100,
|
|
PositionY = 100
|
|
};
|
|
db.WorkflowNodes.Add(startNode);
|
|
|
|
var approvalNode = new WorkflowNode
|
|
{
|
|
Id = approvalNodeId,
|
|
DefinitionId = definitionId,
|
|
NodeType = NodeType.Approval,
|
|
Name = "Approve",
|
|
Config = """{"assigneeRule": "user:00000000-0000-0000-0000-000000000001"}""",
|
|
PositionX = 300,
|
|
PositionY = 100
|
|
};
|
|
db.WorkflowNodes.Add(approvalNode);
|
|
|
|
db.WorkflowEdges.Add(new WorkflowEdge
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DefinitionId = definitionId,
|
|
SourceNodeId = nodeId,
|
|
TargetNodeId = approvalNodeId,
|
|
EdgeType = EdgeType.Normal
|
|
});
|
|
|
|
await db.SaveChangesAsync();
|
|
return db;
|
|
}
|
|
|
|
#region StartWorkflowInstance
|
|
|
|
[Fact]
|
|
public async Task StartInstance_CreatesInstanceWithRunningStatus()
|
|
{
|
|
// Arrange
|
|
await using var db = await SeedPublishedDefinitionAsync("running-test");
|
|
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
|
var command = new StartWorkflowInstanceCommand(
|
|
DefinitionCode: "running-test",
|
|
Title: "Test Instance",
|
|
Variables: null,
|
|
FormDataJson: null
|
|
);
|
|
|
|
// Act
|
|
var instanceId = await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
instanceId.Should().NotBe(Guid.Empty);
|
|
|
|
var instance = await db.WorkflowInstances.FindAsync(instanceId);
|
|
instance.Should().NotBeNull();
|
|
instance!.Status.Should().Be(InstanceStatus.Running);
|
|
instance.Title.Should().Be("Test Instance");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartInstance_CreatesInitialTokenAtStartNode()
|
|
{
|
|
// Arrange
|
|
var startNodeId = Guid.NewGuid();
|
|
await using var db = await SeedPublishedDefinitionAsync("token-test", startNodeId: startNodeId);
|
|
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
|
var command = new StartWorkflowInstanceCommand(
|
|
DefinitionCode: "token-test",
|
|
Title: "Token Test",
|
|
Variables: null,
|
|
FormDataJson: null
|
|
);
|
|
|
|
// Act
|
|
var instanceId = await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert — Start→Approval: Start token consumed, Approval token active
|
|
var tokens = await db.WorkflowTokens
|
|
.Where(t => t.InstanceId == instanceId)
|
|
.ToListAsync();
|
|
|
|
tokens.Should().HaveCount(2);
|
|
tokens.Should().Contain(t => t.NodeId == startNodeId && t.Status == TokenStatus.Consumed);
|
|
tokens.Should().Contain(t => t.Status == TokenStatus.Active);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartInstance_WithInvalidDefinitionCode_ThrowsNotFoundException()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
|
var command = new StartWorkflowInstanceCommand(
|
|
DefinitionCode: "nonexistent-code",
|
|
Title: "Should Fail",
|
|
Variables: null,
|
|
FormDataJson: null
|
|
);
|
|
|
|
// Act
|
|
var act = () => handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<NotFoundException>()
|
|
.WithMessage("*nonexistent-code*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartInstance_WithDisabledDefinition_ThrowsBusinessException()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definition = new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Disabled Workflow",
|
|
Code = "disabled-wf",
|
|
Status = DefinitionStatus.Published,
|
|
Version = 2,
|
|
IsEnabled = false
|
|
};
|
|
db.WorkflowDefinitions.Add(definition);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
|
var command = new StartWorkflowInstanceCommand(
|
|
DefinitionCode: "disabled-wf",
|
|
Title: "Should Fail",
|
|
Variables: null,
|
|
FormDataJson: null
|
|
);
|
|
|
|
// Act
|
|
var act = () => handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<BusinessException>()
|
|
.WithMessage("*disabled*");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WithdrawWorkflowInstance
|
|
|
|
[Fact]
|
|
public async Task WithdrawInstance_TerminatesInstance_WhenInitiatorAndNoTasksProcessed()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
var initiatorId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "Test",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = initiatorId
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
|
|
var token1 = new WorkflowToken
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InstanceId = instanceId,
|
|
NodeId = Guid.NewGuid(),
|
|
Status = TokenStatus.Active
|
|
};
|
|
var token2 = new WorkflowToken
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InstanceId = instanceId,
|
|
NodeId = Guid.NewGuid(),
|
|
Status = TokenStatus.Active
|
|
};
|
|
db.WorkflowTokens.AddRange(token1, token2);
|
|
|
|
// No tasks have been processed (task exists but is pending, not completed)
|
|
var pendingTask = new WorkflowTask
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InstanceId = instanceId,
|
|
TokenId = token1.Id,
|
|
AssigneeId = Guid.NewGuid(),
|
|
Status = TaskStatus.Pending
|
|
};
|
|
db.WorkflowTasks.Add(pendingTask);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
|
var command = new WithdrawWorkflowInstanceCommand(
|
|
InstanceId: instanceId,
|
|
UserId: initiatorId
|
|
);
|
|
|
|
// Act
|
|
await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
var updatedInstance = await db.WorkflowInstances.FindAsync(instanceId);
|
|
updatedInstance.Should().NotBeNull();
|
|
updatedInstance!.Status.Should().Be(InstanceStatus.Terminated);
|
|
|
|
var tokens = await db.WorkflowTokens
|
|
.Where(t => t.InstanceId == instanceId)
|
|
.ToListAsync();
|
|
tokens.Should().OnlyContain(t => t.Status == TokenStatus.Terminated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithdrawInstance_Throws_WhenNotInitiator()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
var initiatorId = Guid.NewGuid();
|
|
var otherUserId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "Test",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = initiatorId
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
|
var command = new WithdrawWorkflowInstanceCommand(
|
|
InstanceId: instanceId,
|
|
UserId: otherUserId
|
|
);
|
|
|
|
// Act
|
|
var act = () => handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<InvalidStateTransitionException>()
|
|
.WithMessage("*initiator*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithdrawInstance_Throws_WhenTaskAlreadyProcessed()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
var initiatorId = Guid.NewGuid();
|
|
var tokenId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "Test",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = initiatorId
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
|
|
var token = new WorkflowToken
|
|
{
|
|
Id = tokenId,
|
|
InstanceId = instanceId,
|
|
NodeId = Guid.NewGuid(),
|
|
Status = TokenStatus.Active
|
|
};
|
|
db.WorkflowTokens.Add(token);
|
|
|
|
// A task that has already been completed (processed)
|
|
var completedTask = new WorkflowTask
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InstanceId = instanceId,
|
|
TokenId = tokenId,
|
|
AssigneeId = Guid.NewGuid(),
|
|
Status = TaskStatus.Approved
|
|
};
|
|
db.WorkflowTasks.Add(completedTask);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
|
var command = new WithdrawWorkflowInstanceCommand(
|
|
InstanceId: instanceId,
|
|
UserId: initiatorId
|
|
);
|
|
|
|
// Act
|
|
var act = () => handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<InvalidStateTransitionException>()
|
|
.WithMessage("*processed*");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region SuspendWorkflowInstance
|
|
|
|
[Fact]
|
|
public async Task SuspendInstance_ChangesToSuspended()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "To Suspend",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = Guid.NewGuid()
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new SuspendWorkflowInstanceCommandHandler(db);
|
|
var command = new SuspendWorkflowInstanceCommand(InstanceId: instanceId);
|
|
|
|
// Act
|
|
await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
var updated = await db.WorkflowInstances.FindAsync(instanceId);
|
|
updated.Should().NotBeNull();
|
|
updated!.Status.Should().Be(InstanceStatus.Suspended);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region ResumeWorkflowInstance
|
|
|
|
[Fact]
|
|
public async Task ResumeInstance_ChangesBackToRunning()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "To Resume",
|
|
Status = InstanceStatus.Suspended,
|
|
InitiatorId = Guid.NewGuid()
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new ResumeWorkflowInstanceCommandHandler(db);
|
|
var command = new ResumeWorkflowInstanceCommand(InstanceId: instanceId);
|
|
|
|
// Act
|
|
await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
var updated = await db.WorkflowInstances.FindAsync(instanceId);
|
|
updated.Should().NotBeNull();
|
|
updated!.Status.Should().Be(InstanceStatus.Running);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region GetWorkflowInstanceList
|
|
|
|
[Fact]
|
|
public async Task GetInstanceList_ReturnsPaged()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
for (int i = 1; i <= 12; i++)
|
|
{
|
|
db.WorkflowInstances.Add(new WorkflowInstance
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DefinitionId = definitionId,
|
|
Title = $"Instance {i}",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = Guid.NewGuid()
|
|
});
|
|
}
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new GetWorkflowInstanceListQueryHandler(db);
|
|
var query = new GetWorkflowInstanceListQuery(
|
|
PageIndex: 1,
|
|
PageSize: 5,
|
|
Status: null,
|
|
InitiatorId: null
|
|
);
|
|
|
|
// Act
|
|
var result = await handler.Handle(query, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Items.Should().HaveCount(5);
|
|
result.Total.Should().Be(12);
|
|
result.PageIndex.Should().Be(1);
|
|
result.PageSize.Should().Be(5);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region GetWorkflowInstanceById
|
|
|
|
[Fact]
|
|
public async Task GetInstanceById_IncludesTokensAndTasks()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var instanceId = Guid.NewGuid();
|
|
var tokenId = Guid.NewGuid();
|
|
var nodeId = Guid.NewGuid();
|
|
|
|
var instance = new WorkflowInstance
|
|
{
|
|
Id = instanceId,
|
|
DefinitionId = Guid.NewGuid(),
|
|
Title = "Detail Test",
|
|
Status = InstanceStatus.Running,
|
|
InitiatorId = Guid.NewGuid()
|
|
};
|
|
db.WorkflowInstances.Add(instance);
|
|
|
|
var token = new WorkflowToken
|
|
{
|
|
Id = tokenId,
|
|
InstanceId = instanceId,
|
|
NodeId = nodeId,
|
|
Status = TokenStatus.Active
|
|
};
|
|
db.WorkflowTokens.Add(token);
|
|
|
|
var task = new WorkflowTask
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
InstanceId = instanceId,
|
|
TokenId = tokenId,
|
|
AssigneeId = Guid.NewGuid(),
|
|
Status = TaskStatus.Pending
|
|
};
|
|
db.WorkflowTasks.Add(task);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new GetWorkflowInstanceByIdQueryHandler(db);
|
|
var query = new GetWorkflowInstanceByIdQuery(Id: instanceId);
|
|
|
|
// Act
|
|
var result = await handler.Handle(query, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Id.Should().Be(instanceId);
|
|
result.Tokens.Should().NotBeNull().And.HaveCount(1);
|
|
result.Tasks.Should().NotBeNull().And.HaveCount(1);
|
|
|
|
result.Tokens[0].NodeId.Should().Be(nodeId);
|
|
result.Tasks[0].Status.Should().Be(TaskStatus.Pending);
|
|
}
|
|
|
|
#endregion
|
|
}
|