- Add FormDefinitionVersion with compare/versions endpoints and schema differ - Add Notification entity, endpoints and application features - Add Scheduler (timeout) and WebhookDispatcher services - Add FormDataValidator/FieldPermissionEvaluator/ReactionEvaluator - Add workflow task mark-read, CC support and SystemUserContext - Add EF migrations for form versions and notifications - Add unit tests for form schema, notifications, scheduler and serialization
469 lines
14 KiB
C#
469 lines
14 KiB
C#
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<WorkflowDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
return new WorkflowDbContext(options);
|
||
}
|
||
|
||
private static async Task<WorkflowDefinition> 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<FormDefinition> 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<NotFoundException>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 不变量:仅 Approval/Cc 节点可绑定表单。非审批/抄送节点(如 Condition)
|
||
/// 携带 FormDefinitionId 时必须被拒绝,与 UI 契约(NodePropertyDrawer.vue:199)一致。
|
||
/// </summary>
|
||
[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<BusinessException>()
|
||
.WithMessage("*表单*审批*抄送*");
|
||
|
||
// 不得创建任何节点
|
||
var nodes = await db.WorkflowNodes.ToListAsync();
|
||
nodes.Should().BeEmpty();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 回归保护:Approval 节点仍可正常绑定表单。
|
||
/// </summary>
|
||
[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<NotFoundException>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 不变量:已存在的 Condition 节点(NodeType 不可变)在更新时绑定表单必须被拒绝。
|
||
/// 因 UpdateNodeCommand 不含 NodeType,校验需基于实体当前的 NodeType。
|
||
/// </summary>
|
||
[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<BusinessException>()
|
||
.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<NotFoundException>();
|
||
}
|
||
|
||
#endregion
|
||
}
|