work-flow/tests/Workflow.Tests/Handlers/NodeCommandHandlerTests.cs
向宁 9f878286e7 feat: form versioning, notification center, scheduler and webhooks
- 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
2026-06-14 15:03:11 +08:00

469 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}