359 lines
11 KiB
C#
359 lines
11 KiB
C#
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Workflow.Application.Features.WorkflowDefinitions.Commands;
|
|
using Workflow.Application.Features.WorkflowDefinitions.Queries;
|
|
using Workflow.Domain.Entities;
|
|
using Workflow.Domain.Enums;
|
|
using Workflow.Domain.Exceptions;
|
|
using Workflow.Infrastructure.Persistence;
|
|
using Xunit;
|
|
|
|
namespace Workflow.Tests.Handlers;
|
|
|
|
public class WorkflowDefinitionHandlerTests
|
|
{
|
|
private static WorkflowDbContext CreateDbContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new WorkflowDbContext(options);
|
|
}
|
|
|
|
#region CreateWorkflowDefinition
|
|
|
|
[Fact]
|
|
public async Task CreateDefinition_SavesToDatabase()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var handler = new CreateWorkflowDefinitionCommandHandler(db);
|
|
var command = new CreateWorkflowDefinitionCommand(
|
|
Name: "Leave Approval",
|
|
Code: "leave-approval",
|
|
Description: "Standard leave approval workflow",
|
|
FormDefinitionId: null
|
|
);
|
|
|
|
// Act
|
|
var result = await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Name.Should().Be("Leave Approval");
|
|
result.Code.Should().Be("leave-approval");
|
|
result.Description.Should().Be("Standard leave approval workflow");
|
|
result.Status.Should().Be(DefinitionStatus.Draft);
|
|
result.Version.Should().Be(1);
|
|
|
|
var entity = await db.WorkflowDefinitions.FirstOrDefaultAsync(d => d.Code == "leave-approval");
|
|
entity.Should().NotBeNull();
|
|
entity!.Name.Should().Be("Leave Approval");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateDefinition_WithDuplicateCode_ThrowsBusinessException()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var existing = new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Existing",
|
|
Code = "duplicate-code",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
};
|
|
db.WorkflowDefinitions.Add(existing);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new CreateWorkflowDefinitionCommandHandler(db);
|
|
var command = new CreateWorkflowDefinitionCommand(
|
|
Name: "New Workflow",
|
|
Code: "duplicate-code",
|
|
Description: "Should fail",
|
|
FormDefinitionId: null
|
|
);
|
|
|
|
// Act
|
|
var act = () => handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<BusinessException>()
|
|
.WithMessage("*duplicate-code*");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region UpdateWorkflowDefinition
|
|
|
|
[Fact]
|
|
public async Task UpdateDefinition_UpdatesFields()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
var existing = new WorkflowDefinition
|
|
{
|
|
Id = definitionId,
|
|
Name = "Old Name",
|
|
Code = "test-workflow",
|
|
Description = "Old description",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
};
|
|
db.WorkflowDefinitions.Add(existing);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new UpdateWorkflowDefinitionCommandHandler(db);
|
|
var command = new UpdateWorkflowDefinitionCommand(
|
|
Id: definitionId,
|
|
Name: "Updated Name",
|
|
Description: "Updated description",
|
|
DefinitionJson: "{\"nodes\":[],\"edges\":[]}",
|
|
FormDefinitionId: null
|
|
);
|
|
|
|
// Act
|
|
var result = await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Name.Should().Be("Updated Name");
|
|
result.Description.Should().Be("Updated description");
|
|
result.DefinitionJson.Should().Be("{\"nodes\":[],\"edges\":[]}");
|
|
|
|
var entity = await db.WorkflowDefinitions.FindAsync(definitionId);
|
|
entity.Should().NotBeNull();
|
|
entity!.Name.Should().Be("Updated Name");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DeleteWorkflowDefinition
|
|
|
|
[Fact]
|
|
public async Task DeleteDefinition_SoftDeletes()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
var existing = new WorkflowDefinition
|
|
{
|
|
Id = definitionId,
|
|
Name = "To Delete",
|
|
Code = "to-delete",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
};
|
|
db.WorkflowDefinitions.Add(existing);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new DeleteWorkflowDefinitionCommandHandler(db);
|
|
var command = new DeleteWorkflowDefinitionCommand(Id: definitionId);
|
|
|
|
// Act
|
|
await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert - soft delete means the entity still exists but IsDeleted = true
|
|
var entity = await db.WorkflowDefinitions
|
|
.IgnoreQueryFilters()
|
|
.FirstOrDefaultAsync(d => d.Id == definitionId);
|
|
entity.Should().NotBeNull();
|
|
entity!.IsDeleted.Should().BeTrue();
|
|
|
|
// Normal query should not return the deleted entity
|
|
// Note: InMemory provider does not support global query filters,
|
|
// so FindAsync still returns the entity. Verify via filtered query instead.
|
|
var activeEntity = await db.WorkflowDefinitions
|
|
.FirstOrDefaultAsync(d => d.Id == definitionId);
|
|
activeEntity?.IsDeleted.Should().BeTrue();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region PublishWorkflowDefinition
|
|
|
|
[Fact]
|
|
public async Task PublishDefinition_ChangesStatusAndIncrementsVersion()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
var existing = new WorkflowDefinition
|
|
{
|
|
Id = definitionId,
|
|
Name = "Publishable Workflow",
|
|
Code = "publishable",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
};
|
|
db.WorkflowDefinitions.Add(existing);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new PublishWorkflowDefinitionCommandHandler(db);
|
|
var command = new PublishWorkflowDefinitionCommand(Id: definitionId);
|
|
|
|
// Act
|
|
await handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
var entity = await db.WorkflowDefinitions.FindAsync(definitionId);
|
|
entity.Should().NotBeNull();
|
|
entity!.Status.Should().Be(DefinitionStatus.Published);
|
|
entity.Version.Should().Be(2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region GetWorkflowDefinitionList
|
|
|
|
[Fact]
|
|
public async Task GetDefinitionList_ReturnsPaged()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
for (int i = 1; i <= 15; i++)
|
|
{
|
|
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = $"Workflow {i}",
|
|
Code = $"workflow-{i}",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
});
|
|
}
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new GetWorkflowDefinitionListQueryHandler(db);
|
|
var query = new GetWorkflowDefinitionListQuery(
|
|
PageIndex: 1,
|
|
PageSize: 10,
|
|
Status: null
|
|
);
|
|
|
|
// Act
|
|
var result = await handler.Handle(query, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Items.Should().HaveCount(10);
|
|
result.Total.Should().Be(15);
|
|
result.PageIndex.Should().Be(1);
|
|
result.PageSize.Should().Be(10);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDefinitionList_FiltersByStatus()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Draft Workflow",
|
|
Code = "draft-wf",
|
|
Status = DefinitionStatus.Draft,
|
|
Version = 1
|
|
});
|
|
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Published Workflow",
|
|
Code = "published-wf",
|
|
Status = DefinitionStatus.Published,
|
|
Version = 2
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new GetWorkflowDefinitionListQueryHandler(db);
|
|
var query = new GetWorkflowDefinitionListQuery(
|
|
PageIndex: 1,
|
|
PageSize: 10,
|
|
Status: DefinitionStatus.Published
|
|
);
|
|
|
|
// Act
|
|
var result = await handler.Handle(query, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Items.Should().HaveCount(1);
|
|
result.Items[0].Status.Should().Be(DefinitionStatus.Published);
|
|
result.Total.Should().Be(1);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region GetWorkflowDefinitionById
|
|
|
|
[Fact]
|
|
public async Task GetDefinitionById_IncludesNodesAndEdges()
|
|
{
|
|
// Arrange
|
|
await using var db = CreateDbContext();
|
|
var definitionId = Guid.NewGuid();
|
|
|
|
var definition = new WorkflowDefinition
|
|
{
|
|
Id = definitionId,
|
|
Name = "Workflow With Graph",
|
|
Code = "graph-wf",
|
|
Status = DefinitionStatus.Published,
|
|
Version = 1
|
|
};
|
|
db.WorkflowDefinitions.Add(definition);
|
|
|
|
var startNode = new WorkflowNode
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DefinitionId = definitionId,
|
|
NodeType = NodeType.Start,
|
|
Name = "Start",
|
|
PositionX = 100,
|
|
PositionY = 100
|
|
};
|
|
var endNode = new WorkflowNode
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DefinitionId = definitionId,
|
|
NodeType = NodeType.End,
|
|
Name = "End",
|
|
PositionX = 500,
|
|
PositionY = 100
|
|
};
|
|
db.WorkflowNodes.AddRange(startNode, endNode);
|
|
|
|
var edge = new WorkflowEdge
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
DefinitionId = definitionId,
|
|
SourceNodeId = startNode.Id,
|
|
TargetNodeId = endNode.Id,
|
|
Label = "to end"
|
|
};
|
|
db.WorkflowEdges.Add(edge);
|
|
await db.SaveChangesAsync();
|
|
|
|
var handler = new GetWorkflowDefinitionByIdQueryHandler(db);
|
|
var query = new GetWorkflowDefinitionByIdQuery(Id: definitionId);
|
|
|
|
// Act
|
|
var result = await handler.Handle(query, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Id.Should().Be(definitionId);
|
|
result.Nodes.Should().NotBeNull().And.HaveCount(2);
|
|
result.Edges.Should().NotBeNull().And.HaveCount(1);
|
|
|
|
result.Nodes.Should().ContainSingle(n => n.NodeType == NodeType.Start);
|
|
result.Nodes.Should().ContainSingle(n => n.NodeType == NodeType.End);
|
|
result.Edges[0].SourceNodeId.Should().Be(startNode.Id);
|
|
result.Edges[0].TargetNodeId.Should().Be(endNode.Id);
|
|
}
|
|
|
|
#endregion
|
|
}
|