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"; } }