using FluentAssertions; using Microsoft.EntityFrameworkCore; using Workflow.Application.Features.WorkflowDefinitions.Commands; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Workflow.Domain.Exceptions; using Workflow.Infrastructure.Persistence; using Xunit; namespace Workflow.Tests.Handlers; public class NodeCommandHandlerTests { private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new WorkflowDbContext(options); } private static async Task SeedDefinition(WorkflowDbContext db) { var definition = new WorkflowDefinition { Id = Guid.NewGuid(), Name = "Test Workflow", Code = $"test-wf-{Guid.NewGuid():N}", Status = DefinitionStatus.Draft, Version = 1 }; db.WorkflowDefinitions.Add(definition); await db.SaveChangesAsync(); return definition; } private static async Task SeedFormDefinition(WorkflowDbContext db) { var form = new FormDefinition { Id = Guid.NewGuid(), Name = "Approval Form", Code = $"approval-form-{Guid.NewGuid():N}", Status = FormStatus.Published, Version = 1, SchemaJson = """{"type":"object","properties":{}}""" }; db.FormDefinitions.Add(form); await db.SaveChangesAsync(); return form; } #region CreateNode [Fact] public async Task CreateNode_ReturnsNodeDto() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var handler = new CreateNodeCommandHandler(db); var command = new CreateNodeCommand( DefinitionId: definition.Id, NodeType: NodeType.Start, Name: "Start Node", Config: null, PositionX: 100, PositionY: 200, FormDefinitionId: null ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Start Node"); result.NodeType.Should().Be(NodeType.Start); result.PositionX.Should().Be(100); result.PositionY.Should().Be(200); result.Config.Should().BeNull(); result.Id.Should().NotBeEmpty(); var entity = await db.WorkflowNodes.FindAsync(result.Id); entity.Should().NotBeNull(); entity!.DefinitionId.Should().Be(definition.Id); } [Fact] public async Task CreateNode_WithFormDefinition_SavesFormRelation() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var form = await SeedFormDefinition(db); var handler = new CreateNodeCommandHandler(db); var command = new CreateNodeCommand( DefinitionId: definition.Id, NodeType: NodeType.Approval, Name: "Manager Approval", Config: null, PositionX: 100, PositionY: 200, FormDefinitionId: form.Id ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.FormDefinitionId.Should().Be(form.Id); result.FormName.Should().Be("Approval Form"); var entity = await db.WorkflowNodes.FindAsync(result.Id); entity.Should().NotBeNull(); entity!.FormDefinitionId.Should().Be(form.Id); } [Fact] public async Task CreateNode_WithMissingDefinition_ThrowsNotFoundException() { // Arrange await using var db = CreateDbContext(); var handler = new CreateNodeCommandHandler(db); var command = new CreateNodeCommand( DefinitionId: Guid.NewGuid(), NodeType: NodeType.Approval, Name: "Orphan Node", Config: null, PositionX: 0, PositionY: 0, FormDefinitionId: null ); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync(); } /// /// 不变量:仅 Approval/Cc 节点可绑定表单。非审批/抄送节点(如 Condition) /// 携带 FormDefinitionId 时必须被拒绝,与 UI 契约(NodePropertyDrawer.vue:199)一致。 /// [Theory] [InlineData(NodeType.Start)] [InlineData(NodeType.End)] [InlineData(NodeType.Condition)] [InlineData(NodeType.Parallel)] [InlineData(NodeType.SubProcess)] public async Task CreateNode_NonApprovalOrCcWithForm_ThrowsBusinessException(NodeType nodeType) { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var form = await SeedFormDefinition(db); var handler = new CreateNodeCommandHandler(db); var command = new CreateNodeCommand( DefinitionId: definition.Id, NodeType: nodeType, Name: $"{nodeType} Node", Config: null, PositionX: 0, PositionY: 0, FormDefinitionId: form.Id ); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync() .WithMessage("*表单*审批*抄送*"); // 不得创建任何节点 var nodes = await db.WorkflowNodes.ToListAsync(); nodes.Should().BeEmpty(); } /// /// 回归保护:Approval 节点仍可正常绑定表单。 /// [Theory] [InlineData(NodeType.Approval)] [InlineData(NodeType.Cc)] public async Task CreateNode_ApprovalOrCcWithForm_Succeeds(NodeType nodeType) { await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var form = await SeedFormDefinition(db); var handler = new CreateNodeCommandHandler(db); var command = new CreateNodeCommand( DefinitionId: definition.Id, NodeType: nodeType, Name: $"{nodeType} Node", Config: null, PositionX: 0, PositionY: 0, FormDefinitionId: form.Id ); var result = await handler.Handle(command, CancellationToken.None); result.FormDefinitionId.Should().Be(form.Id); } #endregion #region UpdateNode [Fact] public async Task UpdateNode_UpdatesNameAndPosition() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var nodeId = Guid.NewGuid(); db.WorkflowNodes.Add(new WorkflowNode { Id = nodeId, DefinitionId = definition.Id, NodeType = NodeType.Approval, Name = "Old Name", PositionX = 50, PositionY = 50 }); await db.SaveChangesAsync(); var handler = new UpdateNodeCommandHandler(db); var command = new UpdateNodeCommand( NodeId: nodeId, Name: "Updated Name", Config: "{\"timeout\": 300}", PositionX: 200, PositionY: 300, FormDefinitionId: null ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Updated Name"); result.Config.Should().Be("{\"timeout\": 300}"); result.PositionX.Should().Be(200); result.PositionY.Should().Be(300); result.Id.Should().Be(nodeId); var entity = await db.WorkflowNodes.FindAsync(nodeId); entity.Should().NotBeNull(); entity!.Name.Should().Be("Updated Name"); entity.PositionX.Should().Be(200); } [Fact] public async Task UpdateNode_WithFormDefinition_UpdatesFormRelation() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var form = await SeedFormDefinition(db); var nodeId = Guid.NewGuid(); db.WorkflowNodes.Add(new WorkflowNode { Id = nodeId, DefinitionId = definition.Id, NodeType = NodeType.Approval, Name = "Approval", PositionX = 50, PositionY = 50 }); await db.SaveChangesAsync(); var handler = new UpdateNodeCommandHandler(db); var command = new UpdateNodeCommand( NodeId: nodeId, Name: "Approval", Config: null, PositionX: 50, PositionY: 50, FormDefinitionId: form.Id ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.FormDefinitionId.Should().Be(form.Id); result.FormName.Should().Be("Approval Form"); var entity = await db.WorkflowNodes.FindAsync(nodeId); entity.Should().NotBeNull(); entity!.FormDefinitionId.Should().Be(form.Id); } [Fact] public async Task UpdateNode_WithMissingNode_ThrowsNotFoundException() { // Arrange await using var db = CreateDbContext(); var handler = new UpdateNodeCommandHandler(db); var command = new UpdateNodeCommand( NodeId: Guid.NewGuid(), Name: "Ghost", Config: null, PositionX: 0, PositionY: 0, FormDefinitionId: null ); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync(); } /// /// 不变量:已存在的 Condition 节点(NodeType 不可变)在更新时绑定表单必须被拒绝。 /// 因 UpdateNodeCommand 不含 NodeType,校验需基于实体当前的 NodeType。 /// [Fact] public async Task UpdateNode_ExistingConditionNodeWithForm_ThrowsBusinessException() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var form = await SeedFormDefinition(db); var nodeId = Guid.NewGuid(); db.WorkflowNodes.Add(new WorkflowNode { Id = nodeId, DefinitionId = definition.Id, NodeType = NodeType.Condition, Name = "Branch", PositionX = 0, PositionY = 0 }); await db.SaveChangesAsync(); var handler = new UpdateNodeCommandHandler(db); var command = new UpdateNodeCommand( NodeId: nodeId, Name: "Branch", Config: null, PositionX: 0, PositionY: 0, FormDefinitionId: form.Id ); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync() .WithMessage("*表单*审批*抄送*"); // 原有 FormDefinitionId 不得被篡改 var entity = await db.WorkflowNodes.FindAsync(nodeId); entity.Should().NotBeNull(); entity!.FormDefinitionId.Should().BeNull(); } #endregion #region DeleteNode [Fact] public async Task DeleteNode_RemovesNode() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var nodeId = Guid.NewGuid(); db.WorkflowNodes.Add(new WorkflowNode { Id = nodeId, DefinitionId = definition.Id, NodeType = NodeType.End, Name = "To Delete", PositionX = 0, PositionY = 0 }); await db.SaveChangesAsync(); var handler = new DeleteNodeCommandHandler(db); var command = new DeleteNodeCommand(NodeId: nodeId); // Act await handler.Handle(command, CancellationToken.None); // Assert var nodes = await db.WorkflowNodes.ToListAsync(); nodes.Should().BeEmpty(); } [Fact] public async Task DeleteNode_RemovesConnectedEdges() { // Arrange await using var db = CreateDbContext(); var definition = await SeedDefinition(db); var sourceNode = new WorkflowNode { Id = Guid.NewGuid(), DefinitionId = definition.Id, NodeType = NodeType.Approval, Name = "Source", PositionX = 0, PositionY = 0 }; var targetNode = new WorkflowNode { Id = Guid.NewGuid(), DefinitionId = definition.Id, NodeType = NodeType.End, Name = "Target", PositionX = 200, PositionY = 0 }; db.WorkflowNodes.AddRange(sourceNode, targetNode); db.WorkflowEdges.Add(new WorkflowEdge { Id = Guid.NewGuid(), DefinitionId = definition.Id, SourceNodeId = sourceNode.Id, TargetNodeId = targetNode.Id, EdgeType = EdgeType.Normal }); await db.SaveChangesAsync(); var handler = new DeleteNodeCommandHandler(db); // Act await handler.Handle(new DeleteNodeCommand(sourceNode.Id), CancellationToken.None); // Assert var edges = await db.WorkflowEdges.ToListAsync(); edges.Should().BeEmpty(); } [Fact] public async Task DeleteNode_WithMissingNode_ThrowsNotFoundException() { // Arrange await using var db = CreateDbContext(); var handler = new DeleteNodeCommandHandler(db); var command = new DeleteNodeCommand(NodeId: Guid.NewGuid()); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert await act.Should().ThrowAsync(); } #endregion }