build-flow.api/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs
xiangning 96f11b5d87 feat(workflow): 添加表单提交功能并优化任务审批端点
- 在 ApproveTaskEndpoint 中返回布尔值响应并使用 Send.OkAsync
- 在 RejectTaskEndpoint 中返回布尔值响应并使用 Send.OkAsync
- 向 BuildFlowButtonActionExecutor 添加 submitForm 动作类型支持
- 实现 SubmitFormAsync 方法处理表单数据提交和保存逻辑
- 添加 ReadGuid 辅助方法解析 JSON 中的 GUID 值
- 更新 RAGStreamEndpoint 中的系统提示词以优化 AI 回答格式
- 为按钮动作执行器添加表单聚合服务和派生服务依赖注入
- 添加单元测试验证提交表单后运行函数的动作链执行流程
2026-07-04 21:38:31 +08:00

347 lines
13 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 BuildFlow.Platform.Application.Auth.Queries;
using FluentAssertions;
using BuildFlow.Shared.Domain.Common;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Workflow.Application.BuildFlow.Buttons.Commands;
using Workflow.Application.BuildFlow.Buttons.Handlers;
using Workflow.Application.BuildFlow.Buttons.Services;
using Workflow.Application.BuildFlow.CustomFunctions.Commands;
using Workflow.Application.BuildFlow.CustomFunctions.Handlers;
using Workflow.Application.BuildFlow.CustomFunctions.Services;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.BuildFlow;
/// <summary>
/// 按钮动作链测试,约束按钮配置驱动后端动作执行,而不是前端写死行为。
/// </summary>
public class BuildFlowButtonActionTests
{
[Fact]
public async Task CreateButtonDefinition_PersistsActionMetadata()
{
await using var db = TestDbFactory.Create();
var handler = new CreateBuildFlowButtonDefinitionCommandHandler(db);
var button = await handler.Handle(
new CreateBuildFlowButtonDefinitionCommand(
null,
null,
null,
null,
"submit",
"提交",
"send",
BuildFlowButtonPlacement.FormToolbar,
"form:submit",
null,
null,
"""{"title":"确认提交?"}""",
"""[]""",
10),
CancellationToken.None);
button.Code.Should().Be("submit");
button.PermissionCode.Should().Be("form:submit");
button.Status.Should().Be(BuildFlowMetadataStatus.Draft);
}
[Fact]
public async Task PublishButtonDefinition_ShouldMakeButtonExecutableByRuntime()
{
await using var db = TestDbFactory.Create();
var button = CreateButton(Guid.NewGuid());
button.Status = BuildFlowMetadataStatus.Draft;
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var handler = new PublishBuildFlowButtonDefinitionCommandHandler(db);
var result = await handler.Handle(
new PublishBuildFlowButtonDefinitionCommand(button.Id),
CancellationToken.None);
result.Status.Should().Be(BuildFlowMetadataStatus.Published);
(await db.BuildFlowButtons.FindAsync(button.Id))!.Status
.Should().Be(BuildFlowMetadataStatus.Published);
}
[Fact]
public async Task DisableButtonDefinition_ShouldRemoveButtonFromRuntime()
{
await using var db = TestDbFactory.Create();
var button = CreateButton(Guid.NewGuid());
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var handler = new DisableBuildFlowButtonDefinitionCommandHandler(db);
var result = await handler.Handle(
new DisableBuildFlowButtonDefinitionCommand(button.Id),
CancellationToken.None);
result.Status.Should().Be(BuildFlowMetadataStatus.Disabled);
(await db.BuildFlowButtons.FindAsync(button.Id))!.Status
.Should().Be(BuildFlowMetadataStatus.Disabled);
}
[Fact]
public async Task ExecuteAsync_RunFunctionAction_ReturnsFunctionResultAndWritesLog()
{
await using var db = TestDbFactory.Create();
var function = await CreatePublishedFunctionAsync(db);
var button = CreateButton(function.Id);
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var executor = new BuildFlowButtonActionExecutor(
db,
new CustomFunctionExecutionService(db));
var result = await executor.ExecuteAsync(
button.Id,
"""{"amount":1200}""",
CancellationToken.None);
result.Success.Should().BeTrue();
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task ExecuteAsync_SubmitFormThenRunFunction_PersistsFormDataAndContinuesActionChain()
{
await using var db = TestDbFactory.Create();
var formDefinition = CreatePublishedFormDefinition(Guid.NewGuid());
db.FormDefinitions.Add(formDefinition);
var function = await CreatePublishedFunctionAsync(db);
var button = CreateSubmitAndRunButton(function.Id);
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var executor = new BuildFlowButtonActionExecutor(
db,
new CustomFunctionExecutionService(db));
var result = await executor.ExecuteAsync(
button.Id,
$$"""
{
"formDefinitionId": "{{formDefinition.Id}}",
"values": { "name": "张三", "amount": 1200 }
}
""",
CancellationToken.None);
result.Success.Should().BeTrue();
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
var saved = await db.FormData.SingleAsync(f => f.FormDefinitionId == formDefinition.Id);
saved.InstanceId.Should().NotBe(Guid.Empty);
saved.DataJson.Should().Be("""{"name":"张三","amount":1200}""");
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task ExecuteAsync_WhenButtonRequiresPermission_RejectsExecutionWithoutPermissionContext()
{
await using var db = TestDbFactory.Create();
var function = await CreatePublishedFunctionAsync(db);
var button = CreateButton(function.Id);
button.PermissionCode = "form:submit";
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var executor = new BuildFlowButtonActionExecutor(
db,
new CustomFunctionExecutionService(db));
var act = () => executor.ExecuteAsync(
button.Id,
"""{"amount":1200}""",
CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*form:submit*");
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
}
[Fact]
public async Task ExecuteAsync_WhenButtonRequiresPermissionAndUserHasAccess_ExecutesAction()
{
await using var db = TestDbFactory.Create();
var function = await CreatePublishedFunctionAsync(db);
var button = CreateButton(function.Id);
button.PermissionCode = "form:submit";
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var sender = new TestSender(["form:submit"]);
var executor = new BuildFlowButtonActionExecutor(
db,
new CustomFunctionExecutionService(db),
sender,
new TestCurrentUserContext("user-1"));
var result = await executor.ExecuteAsync(
button.Id,
"""{"amount":1200}""",
CancellationToken.None);
result.Success.Should().BeTrue();
result.Results.Should().ContainSingle().Which.ResultJson.Should().Be("\"high\"");
sender.RequestedUserId.Should().Be("user-1");
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task ExecuteAsync_WhenActionFails_DoesNotPersistActionLog()
{
await using var db = TestDbFactory.Create();
var button = CreateButton(Guid.NewGuid());
db.BuildFlowButtons.Add(button);
await db.SaveChangesAsync();
var executor = new BuildFlowButtonActionExecutor(
db,
new CustomFunctionExecutionService(db));
var act = () => executor.ExecuteAsync(button.Id, "{}", CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>();
(await db.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(0);
}
private static async Task<CustomFunction> CreatePublishedFunctionAsync(WorkflowDbContext db)
{
var createHandler = new CreateCustomFunctionCommandHandler(db);
var publishHandler = new PublishCustomFunctionCommandHandler(db);
var function = await createHandler.Handle(
new CreateCustomFunctionCommand(
"button-amount-level",
"按钮金额分级",
null,
"((System.Text.Json.JsonElement)Variables[\"amount\"]).GetDecimal() > 1000 ? \"high\" : \"normal\"",
null,
null,
5000),
CancellationToken.None);
await publishHandler.Handle(new PublishCustomFunctionCommand(function.Id), CancellationToken.None);
return await db.CustomFunctions.FindAsync(function.Id) ?? throw new InvalidOperationException();
}
private static FormDefinition CreatePublishedFormDefinition(Guid formDefinitionId)
=> new()
{
Id = formDefinitionId,
Name = "按钮提交表单",
Code = $"button-submit-{formDefinitionId:N}",
Version = 1,
Status = FormStatus.Published,
SchemaJson = """
{
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "姓名",
"required": true,
"x-decorator": "FormItem",
"x-component": "Input"
},
"amount": {
"type": "number",
"title": "金额",
"x-decorator": "FormItem",
"x-component": "InputNumber"
}
}
}
""",
};
private static BuildFlowButtonDefinition CreateButton(Guid functionId)
=> new()
{
Id = Guid.NewGuid(),
Code = "submit-and-run",
Label = "提交并计算",
Placement = BuildFlowButtonPlacement.FormToolbar,
Status = BuildFlowMetadataStatus.Published,
ActionsJson = $$"""
[
{
"type": "runFunction",
"target": "{{functionId}}",
"params": { "amount": "$input.amount" },
"runPolicy": "stopOnFailure"
}
]
""",
};
private static BuildFlowButtonDefinition CreateSubmitAndRunButton(Guid functionId)
=> new()
{
Id = Guid.NewGuid(),
Code = "submit-form-and-run",
Label = "提交表单并计算",
Placement = BuildFlowButtonPlacement.FormToolbar,
Status = BuildFlowMetadataStatus.Published,
ActionsJson = $$"""
[
{
"type": "submitForm"
},
{
"type": "runFunction",
"target": "{{functionId}}",
"params": { "amount": "$input.values.amount" },
"runPolicy": "stopOnFailure"
}
]
""",
};
private sealed class TestCurrentUserContext(string userId) : ICurrentUserContext
{
public string UserId { get; } = userId;
public string IPAddress => "127.0.0.1";
}
private sealed class TestSender(IReadOnlyCollection<string> accessCodes) : ISender
{
public string? RequestedUserId { get; private set; }
public Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
if (request is GetAccessCodesQuery query && typeof(TResponse) == typeof(List<string>))
{
RequestedUserId = query.UserId;
return Task.FromResult((TResponse)(object)accessCodes.ToList());
}
throw new NotSupportedException($"Unexpected request {request.GetType().Name}");
}
public Task Send<TRequest>(TRequest request, CancellationToken cancellationToken = default)
where TRequest : IRequest
=> throw new NotSupportedException($"Unexpected request {typeof(TRequest).Name}");
public Task<object?> Send(object request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException($"Unexpected request {request.GetType().Name}");
public IAsyncEnumerable<TResponse> CreateStream<TResponse>(
IStreamRequest<TResponse> request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}");
public IAsyncEnumerable<object?> CreateStream(object request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException($"Unexpected stream request {request.GetType().Name}");
}
}