build-flow.api/tests/BuildFlow.Workflow.Tests/Handlers/WorkflowDefinitionHandlerTests.cs
xiangning 74e99850e5 feat(workflow): 添加钉钉式节点级字段权限和依次审批功能
- 实现钉钉式节点级字段权限:从审批节点config解析fieldPermissions进行表单校验
- 添加依次审批模式支持:按顺序逐个创建审批任务并推进流程
- 扩展工作流定义实体:新增DefinitionTree字段存储钉式审批流树JSON
- 更新表单验证器:支持节点级字段权限配置影响必填校验逻辑
- 重构后台服务:修复OperationCanceledException处理防止进程崩溃
- 扩展脚本执行服务:支持加载各模块Application/Domain程序集
- 更新菜单配置:调整组件管理和脚本函数菜单顺序
- 完善工作流引擎:修复实例图加载和任务完成逻辑
2026-07-17 17:05:35 +08:00

359 lines
12 KiB
C#
Raw 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.Application.Features.WorkflowDefinitions.Handlers;
using Workflow.Application.Features.WorkflowDefinitions.Queries;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.Handlers;
public class WorkflowDefinitionHandlerTests
{
private static WorkflowDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new WorkflowDbContext(options);
db.SavingChanges += AuditStub.OnSave;
return db; }
#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<BusinessException>()
.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\":[]}",
DefinitionTree: null,
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();
// 发布前需有合法的 X6 graph JSON(含 start + end + 连通边),否则图校验拦截
var startId = Guid.NewGuid();
var endId = Guid.NewGuid();
var edgeId = Guid.NewGuid();
var definitionJson =
"{\"nodes\":[" +
"{\"id\":\"" + startId + "\",\"shape\":\"wf-start\",\"x\":0,\"y\":0,\"data\":{\"title\":\"开始\"}}," +
"{\"id\":\"" + endId + "\",\"shape\":\"wf-end\",\"x\":0,\"y\":200,\"data\":{\"title\":\"结束\"}}" +
"],\"edges\":[" +
"{\"id\":\"" + edgeId + "\",\"shape\":\"wf-edge\"," +
"\"source\":{\"cell\":\"" + startId + "\",\"port\":\"bottom\"}," +
"\"target\":{\"cell\":\"" + endId + "\",\"port\":\"top\"}," +
"\"data\":{\"edgeType\":0}}" +
"]}";
var existing = new WorkflowDefinition
{
Id = definitionId,
Name = "Publishable Workflow",
Code = "publishable",
Status = DefinitionStatus.Draft,
Version = 1,
DefinitionJson = definitionJson
};
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);
// 发布应写入不可变快照(版本隔离)
var snapshot = await db.WorkflowDefinitionSnapshots
.FirstOrDefaultAsync(s => s.DefinitionId == definitionId && s.Version == 2);
snapshot.Should().NotBeNull();
snapshot!.DefinitionJson.Should().Be(definitionJson);
}
#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_ReturnsJsonAsSourceOfTruth()
{
// Arrange:JSON 单一事实源下,节点/边数据在 DefinitionJson,Nodes/Edges 集合置空
await using var db = CreateDbContext();
var definitionId = Guid.NewGuid();
var startId = Guid.NewGuid();
var endId = Guid.NewGuid();
var definitionJson =
"{\"nodes\":[" +
"{\"id\":\"" + startId + "\",\"shape\":\"wf-start\",\"x\":100,\"y\":100,\"data\":{\"title\":\"Start\"}}," +
"{\"id\":\"" + endId + "\",\"shape\":\"wf-end\",\"x\":500,\"y\":100,\"data\":{\"title\":\"End\"}}" +
"],\"edges\":[" +
"{\"id\":\"" + Guid.NewGuid() + "\",\"shape\":\"wf-edge\"," +
"\"source\":{\"cell\":\"" + startId + "\",\"port\":\"bottom\"}," +
"\"target\":{\"cell\":\"" + endId + "\",\"port\":\"top\"}," +
"\"data\":{\"edgeType\":0,\"label\":\"to end\"}}" +
"]}";
var definition = new WorkflowDefinition
{
Id = definitionId,
Name = "Workflow With Graph",
Code = "graph-wf",
Status = DefinitionStatus.Published,
Version = 1,
DefinitionJson = definitionJson
};
db.WorkflowDefinitions.Add(definition);
await db.SaveChangesAsync();
var handler = new GetWorkflowDefinitionByIdQueryHandler(db);
var query = new GetWorkflowDefinitionByIdQuery(Id: definitionId);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert:JSON 是权威源,集合为空(P4 清理时一并移除)
result.Should().NotBeNull();
result.Id.Should().Be(definitionId);
result.DefinitionJson.Should().Be(definitionJson);
result.Nodes.Should().NotBeNull().And.BeEmpty();
result.Edges.Should().NotBeNull().And.BeEmpty();
}
#endregion
}