using FluentAssertions;
using BuildFlow.Shared.Domain.Common;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Scripting.Application.DTOs;
using Scripting.Application.Services;
using Scripting.Domain.Entities.ScriptFunction;
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;
///
/// 按钮动作链测试,约束按钮配置驱动后端动作执行,而不是前端写死行为。
///
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 ───
///
/// 回归测试(spec §13 #7 按钮动作链):runFunction 动作通过 IScriptExecutionService 执行脚本。
/// 验证 T6 的 rewiring 后执行器正确委托给新的脚本执行服务,且返回成功结果。
///
[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 ScriptFunctionEntity
{
Id = scriptId,
Name = "calc",
DisplayName = "计算",
Code = "return 1;",
Status = ScriptStatusEnum.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();
execService.ExecuteByIdAsync(
scriptId,
Arg.Is(j => j.Contains("\"amount\":100")),
Arg.Any())
.Returns(new ScriptExecutionResult(
ScriptExecutionStatusEnum.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(j => j.Contains("\"amount\":100")),
Arg.Any());
// 整体执行日志应写入 WorkflowDbContext
(await wfDb.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
}
/// 未发布按钮不可执行(状态守卫)。
[Fact]
public async Task ExecuteAsync_DraftButton_ThrowsBusinessException()
{
await using var wfDb = TestDbFactory.Create();
await using var scriptingDb = CreateScriptingDb();
var execService = Substitute.For();
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().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"
}
]
""",
};
/// 新建带审计模拟的 InMemory ScriptingDbContext(镜像 Scripting.Tests 模式)。
private static ScriptingDbContext CreateScriptingDb()
{
var options = new DbContextOptionsBuilder()
.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";
}
}