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() .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() .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 }