From f18a48379f3cd20ce148c64b539ce219222f4aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com> Date: Thu, 21 May 2026 14:42:51 +0800 Subject: [PATCH] feat: add Node CRUD commands with tests --- .../Commands/CreateNodeCommand.cs | 51 +++++ .../Commands/DeleteNodeCommand.cs | 22 ++ .../Commands/UpdateNodeCommand.cs | 40 ++++ .../Handlers/NodeCommandHandlerTests.cs | 215 ++++++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs create mode 100644 src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs create mode 100644 src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs create mode 100644 tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs new file mode 100644 index 0000000..1b3331e --- /dev/null +++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/CreateNodeCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Workflow.Application.Features.WorkflowDefinitions.DTOs; +using Workflow.Domain.Entities; +using Workflow.Domain.Enums; +using Workflow.Domain.Exceptions; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.Features.WorkflowDefinitions.Commands; + +public record CreateNodeCommand( + Guid DefinitionId, + NodeType NodeType, + string Name, + string? Config, + int PositionX, + int PositionY +) : IRequest; + +public class CreateNodeCommandHandler(WorkflowDbContext db) + : IRequestHandler +{ + public async Task Handle(CreateNodeCommand request, CancellationToken cancellationToken) + { + var definition = await db.WorkflowDefinitions.FindAsync([request.DefinitionId], cancellationToken) + ?? throw new NotFoundException($"Workflow definition '{request.DefinitionId}' not found."); + + var entity = new WorkflowNode + { + Id = Guid.NewGuid(), + DefinitionId = request.DefinitionId, + NodeType = request.NodeType, + Name = request.Name, + Config = request.Config, + PositionX = request.PositionX, + PositionY = request.PositionY + }; + + db.WorkflowNodes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return new WorkflowNodeDto( + entity.Id, + entity.NodeType, + entity.Name, + entity.Config, + entity.PositionX, + entity.PositionY + ); + } +} diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs new file mode 100644 index 0000000..a7c63b0 --- /dev/null +++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/DeleteNodeCommand.cs @@ -0,0 +1,22 @@ +using MediatR; +using Workflow.Domain.Exceptions; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.Features.WorkflowDefinitions.Commands; + +public record DeleteNodeCommand(Guid NodeId) : IRequest; + +public class DeleteNodeCommandHandler(WorkflowDbContext db) + : IRequestHandler +{ + public async Task Handle(DeleteNodeCommand request, CancellationToken cancellationToken) + { + var entity = await db.WorkflowNodes.FindAsync([request.NodeId], cancellationToken) + ?? throw new NotFoundException($"Workflow node '{request.NodeId}' not found."); + + db.WorkflowNodes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs new file mode 100644 index 0000000..988044b --- /dev/null +++ b/src/Workflow.Application/Features/WorkflowDefinitions/Commands/UpdateNodeCommand.cs @@ -0,0 +1,40 @@ +using MediatR; +using Workflow.Application.Features.WorkflowDefinitions.DTOs; +using Workflow.Domain.Exceptions; +using Workflow.Infrastructure.Persistence; + +namespace Workflow.Application.Features.WorkflowDefinitions.Commands; + +public record UpdateNodeCommand( + Guid NodeId, + string Name, + string? Config, + int PositionX, + int PositionY +) : IRequest; + +public class UpdateNodeCommandHandler(WorkflowDbContext db) + : IRequestHandler +{ + public async Task Handle(UpdateNodeCommand request, CancellationToken cancellationToken) + { + var entity = await db.WorkflowNodes.FindAsync([request.NodeId], cancellationToken) + ?? throw new NotFoundException($"Workflow node '{request.NodeId}' not found."); + + entity.Name = request.Name; + entity.Config = request.Config; + entity.PositionX = request.PositionX; + entity.PositionY = request.PositionY; + + await db.SaveChangesAsync(cancellationToken); + + return new WorkflowNodeDto( + entity.Id, + entity.NodeType, + entity.Name, + entity.Config, + entity.PositionX, + entity.PositionY + ); + } +} diff --git a/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs b/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs new file mode 100644 index 0000000..bede064 --- /dev/null +++ b/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs @@ -0,0 +1,215 @@ +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; + } + + #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 + ); + + // 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_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 + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + #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 + ); + + // 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_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 + ); + + // Act + var act = () => handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + #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_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 +}