- 引入 Microsoft.Extensions.AI.Abstractions 依赖包 - 重构 ChatAgentService 实现,支持多轮对话历史 - 更新 IAIChatAgent 接口,统一使用 ChatMessage 消息格式 - 优化流式输出处理逻辑,提升性能表现 feat(im): 增强即时通讯会话功能支持置顶备注免打扰 - 扩展会话 DTO,新增 IsPinned、IsMuted、OtherUserRemark 属性 - 扩展 ConversationParticipant 实体,新增置顶和备注相关字段 - 实现会话置顶功能,支持按置顶时间和最后消息时间排序 - 添加会话备注功能,私聊场景支持自定义备注名称 - 完善免打扰功能,支持用户级别静音设置 feat(file): 实现文件预览服务支持多种鉴权方式 - 新增文件预览端点,支持流式返回文件内容 - 实现三种鉴权方式:授权头、JWT令牌、内部签名 - 集成 KKFileView 预览服务,支持 Office 文档在线预览 - 优化文件访问安全控制,避免直接暴露 S3 链接 feat(rag): 增强 RAG 会话管理支持知识库关联 - 扩展会话创建功能,支持关联知识库 - 新增会话更新功能,支持重命名和切换知识库 - 优化会话 DTO 结构,支持知识库 ID 关联 - 实现知识库检索增强对话功能 feat(workflow): 废弃旧工作流定义接口迁移到图形化存储 - 标记旧的节点边操作端点为废弃状态 - 实现工作流定义图形化存储迁移 - 统一使用 JSON 格式作为单一事实源 - 优化工作流定义查询性能表现 chore(deps): 更新项目依赖包版本解决版本冲突问题 - 添加 Microsoft.Extensions.AI.Abstractions 依赖 - 显式声明 OpenAI 2.11.0 版本解决 CS1705 冲突 - 更新相关依赖包至兼容版本 fix(host): 修复文件预览响应包装中间件配置问题 - 添加文件预览接口到响应绕过前缀列表 - 避免文件流被 JSON 包装和内存缓冲影响性能 - 优化文件下载和预览的中间件处理逻辑 chore(im): 修复 IM 设计时数据库上下文工厂路径问题 - 优化 ImDbContextDesignTimeFactory 路径查找逻辑 - 修复数据库迁移时连接字符串获取问题 - 更新实体映射命名空间引用
358 lines
12 KiB
C#
358 lines
12 KiB
C#
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\":[]}",
|
||
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
|
||
}
|