build-flow.api/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs
xiangning e9cc9247f2 test(scripting): 集成测试 + 按钮链回归测试
ScriptExecutionServiceTests:
- 脚本执行返回结果(简单+变量读取)
- 沙箱拦截(File/Reflection 禁用 API 抛异常)
- 草稿可测(ExecuteTestAsync 跳过 Published)
- 状态守卫(Draft/Disabled 拒绝正式执行)
- 执行日志写入(success/timeout/failed)
- 业务异常冒泡不包裹
- 编译缓存(同代码两次执行)
- Logger 输出收集
- ExecuteByNameAsync 按名执行

ScriptCommandHandlerTests:
- Create 唯一名校验 + 默认超时
- Update Disabled 拒绝 + Published 自增版本
- Delete Published 拒绝 + Draft 软删除
- Publish 状态+版本

BuildFlowButtonActionTests(回归):
- ExecuteAsync_RunFunctionAction 通过 IScriptExecutionService 执行脚本
- ExecuteAsync_DraftButton 状态守卫

Task T9 of scripting module refactor (final)
2026-07-07 11:12:28 +08:00

241 lines
9.0 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 BuildFlow.Shared.Domain.Common;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
using Scripting.Domain.Entities;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
using Workflow.Application.BuildFlow.Buttons.Commands;
using Workflow.Application.BuildFlow.Buttons.Handlers;
using Workflow.Application.BuildFlow.Buttons.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":"确认提交?"}""",
null,
"""[]""",
null,
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();
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();
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);
}
// ── T6 回归:执行器已改造为依赖 IScriptExecutionService + ScriptingDbContext ───
/// <summary>
/// 回归测试(spec §13 #7 按钮动作链):runFunction 动作通过 IScriptExecutionService 执行脚本。
/// 验证 T6 的 rewiring 后执行器正确委托给新的脚本执行服务,且返回成功结果。
/// </summary>
[Fact]
public async Task ExecuteAsync_RunFunctionAction_ExecutesScriptViaService()
{
// Setup: WorkflowDbContext(InMemory)+ ScriptingDbContext(InMemory)+ Mock IScriptExecutionService
await using var wfDb = TestDbFactory.Create();
await using var scriptingDb = CreateScriptingDb();
var scriptId = Guid.NewGuid();
// 脚本需存在(执行器 EnsureFunctionScopeAsync 会查 ScriptingDbContext)
scriptingDb.Scripts.Add(new ScriptFunction
{
Id = scriptId,
Name = "calc",
DisplayName = "计算",
Code = "return 1;",
Status = ScriptStatus.Published,
TimeoutMs = 5000,
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1",
});
await scriptingDb.SaveChangesAsync();
var buttonId = Guid.NewGuid();
wfDb.BuildFlowButtons.Add(new BuildFlowButtonDefinition
{
Id = buttonId,
Code = "run",
Label = "运行脚本",
Placement = BuildFlowButtonPlacement.FormToolbar,
Status = BuildFlowMetadataStatus.Published,
// 无 PermissionCode → 跳过权限校验,专注验证执行链路
ActionsJson = $$"""
[
{
"type": "runFunction",
"target": "{{scriptId}}",
"params": { "amount": "$input.amount" }
}
]
""",
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1",
});
await wfDb.SaveChangesAsync();
// Mock 执行服务:返回已知成功结果,并记录被调用的 scriptId + inputJson
var execService = Substitute.For<IScriptExecutionService>();
execService.ExecuteByIdAsync(
scriptId,
Arg.Is<string>(j => j.Contains("\"amount\":100")),
Arg.Any<CancellationToken>())
.Returns(new ScriptExecutionResult(
ScriptExecutionStatus.Success, "100", null, 5, null));
var executor = new BuildFlowButtonActionExecutor(wfDb, scriptingDb, execService);
var result = await executor.ExecuteAsync(buttonId, """{"amount":100}""", CancellationToken.None);
// 断言:执行服务被正确调用,按钮执行返回成功
result.Success.Should().BeTrue();
await execService.Received(1).ExecuteByIdAsync(
scriptId,
Arg.Is<string>(j => j.Contains("\"amount\":100")),
Arg.Any<CancellationToken>());
// 整体执行日志应写入 WorkflowDbContext
(await wfDb.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
}
/// <summary>未发布按钮不可执行(状态守卫)。</summary>
[Fact]
public async Task ExecuteAsync_DraftButton_ThrowsBusinessException()
{
await using var wfDb = TestDbFactory.Create();
await using var scriptingDb = CreateScriptingDb();
var execService = Substitute.For<IScriptExecutionService>();
var buttonId = Guid.NewGuid();
wfDb.BuildFlowButtons.Add(new BuildFlowButtonDefinition
{
Id = buttonId,
Code = "draft-btn",
Label = "草稿按钮",
Placement = BuildFlowButtonPlacement.FormToolbar,
Status = BuildFlowMetadataStatus.Draft, // 未发布
ActionsJson = "[]",
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1",
});
await wfDb.SaveChangesAsync();
var executor = new BuildFlowButtonActionExecutor(wfDb, scriptingDb, execService);
var act = () => executor.ExecuteAsync(buttonId, "{}", CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>().WithMessage("*未发布*");
// 执行服务不应被调用
await execService.DidNotReceiveWithAnyArgs().ExecuteByIdAsync(default, default!, default);
}
// 注:原 7 个 BuildFlowButtonActionExecutor 执行器用例(基于已删除的 CustomFunctionExecutionService 链路)
// 已在 T6 删除。上方两个用例基于新的 IScriptExecutionService 链路重写,作为动作链回归覆盖。
private static BuildFlowButtonDefinition CreateButton()
=> new()
{
Id = Guid.NewGuid(),
Code = "submit-and-run",
Label = "提交并计算",
Placement = BuildFlowButtonPlacement.FormToolbar,
Status = BuildFlowMetadataStatus.Published,
ActionsJson = """
[
{
"type": "runFunction",
"target": "00000000-0000-0000-0000-000000000001",
"params": { "amount": "$input.amount" },
"runPolicy": "stopOnFailure"
}
]
""",
};
/// <summary>新建带审计模拟的 InMemory ScriptingDbContext(镜像 Scripting.Tests 模式)。</summary>
private static ScriptingDbContext CreateScriptingDb()
{
var options = new DbContextOptionsBuilder<ScriptingDbContext>()
.UseInMemoryDatabase($"wf-scripting-test-{Guid.NewGuid()}")
.Options;
// 传入测试用户上下文以激活 AuditInterceptor,自动填充审计字段
return new ScriptingDbContext(options, new TestCurrentUserContext());
}
private sealed class TestCurrentUserContext : ICurrentUserContext
{
public string UserId => "test-user";
public string IPAddress => "127.0.0.1";
}
}