work-flow/tests/Workflow.Tests/Handlers/WorkflowInstanceHandlerTests.cs

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
}