From e9cc9247f28108367a81590cd056210682596a3c Mon Sep 17 00:00:00 2001 From: xiangning Date: Tue, 7 Jul 2026 11:12:28 +0800 Subject: [PATCH] =?UTF-8?q?test(scripting):=20=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20+=20=E6=8C=89=E9=92=AE=E9=93=BE=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../BuildFlow.Scripting.Tests.csproj | 3 + .../ScriptCommandHandlerTests.cs | 184 ++++++++++ .../ScriptExecutionServiceTests.cs | 318 ++++++++++++++++++ .../ScriptingTestDbFactory.cs | 28 ++ .../BuildFlow.Workflow.Tests.csproj | 1 + .../BuildFlow/BuildFlowButtonActionTests.cs | 136 +++++++- 6 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs create mode 100644 tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs create mode 100644 tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs diff --git a/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj b/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj index ae15c64..ec048f3 100644 --- a/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj +++ b/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj @@ -12,8 +12,11 @@ + + + diff --git a/tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs b/tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs new file mode 100644 index 0000000..ea71e9d --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs @@ -0,0 +1,184 @@ +using BuildFlow.Shared.Domain.Exceptions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Scripting.Application.Commands; +using Scripting.Application.Handlers; +using Scripting.Domain.Entities; +using Scripting.Domain.Enums; +using Scripting.Infrastructure.Persistence; +using Xunit; + +namespace BuildFlow.Scripting.Tests; + +/// +/// 脚本 CQRS 命令处理器测试(spec §13 #6 可编辑性、生命周期守卫)。 +/// 用真实 InMemory ScriptingDbContext 直接实例化 handler,覆盖: +/// Create(唯一名校验 + 默认超时)、Update(Disabled 拒绝 + Published 自增版本)、 +/// Delete(Published 拒绝 + Draft 软删除)、Publish(状态 + 版本)。 +/// +public class ScriptCommandHandlerTests +{ + // ── Create ───────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateScript_DuplicateName_ThrowsBusinessException() + { + await using var db = ScriptingTestDbFactory.Create(); + await SeedAsync(db, name: "dup-fn"); + var handler = new CreateScriptCommandHandler(db); + + var act = () => handler.Handle( + new CreateScriptCommand("dup-fn", "显示名", null, "return 1;", null, null, 0), + CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*已存在*"); + } + + [Fact] + public async Task CreateScript_DefaultsTimeoutTo5000() + { + await using var db = ScriptingTestDbFactory.Create(); + var handler = new CreateScriptCommandHandler(db); + + var dto = await handler.Handle( + new CreateScriptCommand("default-timeout", "默认超时", null, "return 1;", null, null, 0), + CancellationToken.None); + + dto.TimeoutMs.Should().Be(5000); + dto.Status.Should().Be(ScriptStatus.Draft); + dto.Version.Should().Be(1); + var entity = await db.Scripts.FindAsync(dto.Id); + entity.Should().NotBeNull(); + entity!.TimeoutMs.Should().Be(5000); + } + + [Fact] + public async Task CreateScript_NewName_PersistsEntity() + { + await using var db = ScriptingTestDbFactory.Create(); + var handler = new CreateScriptCommandHandler(db); + + var dto = await handler.Handle( + new CreateScriptCommand("unique-fn", "唯一函数", "desc", "return 42;", null, null, 3000), + CancellationToken.None); + + dto.Name.Should().Be("unique-fn"); + dto.Code.Should().Be("return 42;"); + dto.TimeoutMs.Should().Be(3000); + } + + // ── Update ───────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateScript_DisabledScript_Throws() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "disabled-fn", status: ScriptStatus.Disabled); + var handler = new UpdateScriptCommandHandler(db); + + var act = () => handler.Handle( + new UpdateScriptCommand(script.Id, "新名", null, "return 2;", null, null, 5000), + CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*不能编辑*"); + } + + [Fact] + public async Task UpdateScript_Published_IncrementsVersion() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "pub-fn", status: ScriptStatus.Published, version: 3); + var handler = new UpdateScriptCommandHandler(db); + + var dto = await handler.Handle( + new UpdateScriptCommand(script.Id, "改后", null, "return 99;", null, null, 5000), + CancellationToken.None); + + dto.Version.Should().Be(4); + dto.DisplayName.Should().Be("改后"); + dto.Code.Should().Be("return 99;"); + dto.Status.Should().Be(ScriptStatus.Published); // 保持已发布 + } + + [Fact] + public async Task UpdateScript_Draft_KeepsVersionAtOne() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "draft-fn", status: ScriptStatus.Draft); + var handler = new UpdateScriptCommandHandler(db); + + var dto = await handler.Handle( + new UpdateScriptCommand(script.Id, "草稿改", null, "return 1;", null, null, 5000), + CancellationToken.None); + + dto.Version.Should().Be(1); // 草稿编辑不递增版本 + } + + // ── Delete ───────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteScript_Published_Throws() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "del-pub", status: ScriptStatus.Published); + var handler = new DeleteScriptCommandHandler(db); + + var act = () => handler.Handle(new DeleteScriptCommand(script.Id), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*不能删除*"); + } + + [Fact] + public async Task DeleteScript_Draft_SoftDeletes() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "del-draft", status: ScriptStatus.Draft); + var handler = new DeleteScriptCommandHandler(db); + + await handler.Handle(new DeleteScriptCommand(script.Id), CancellationToken.None); + + var entity = await db.Scripts.IgnoreQueryFilters().FirstAsync(x => x.Id == script.Id); + entity.IsDeleted.Should().BeTrue(); + } + + // ── Publish ──────────────────────────────────────────────────────────── + + [Fact] + public async Task PublishScript_SetsPublishedAndIncrementsVersion() + { + await using var db = ScriptingTestDbFactory.Create(); + var script = await SeedAsync(db, name: "to-publish", status: ScriptStatus.Draft, version: 1); + var handler = new PublishScriptCommandHandler(db); + + var dto = await handler.Handle(new PublishScriptCommand(script.Id), CancellationToken.None); + + dto.Status.Should().Be(ScriptStatus.Published); + dto.Version.Should().Be(2); + } + + // ── 夹具 ─────────────────────────────────────────────────────────────── + + private static async Task SeedAsync( + ScriptingDbContext db, + string name, + ScriptStatus status = ScriptStatus.Draft, + int version = 1) + { + var script = new ScriptFunction + { + Id = Guid.NewGuid(), + Name = name, + DisplayName = name, + Code = "return 1;", + Status = status, + Version = version, + TimeoutMs = 5000, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }; + db.Scripts.Add(script); + await db.SaveChangesAsync(); + return script; + } +} diff --git a/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs new file mode 100644 index 0000000..bdac295 --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs @@ -0,0 +1,318 @@ +using BuildFlow.Shared.Domain.Common; +using BuildFlow.Shared.Domain.Exceptions; +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Scripting.Application.Services; +using Scripting.Domain.Entities; +using Scripting.Domain.Enums; +using Scripting.Infrastructure.Persistence; +using Xunit; + +namespace BuildFlow.Scripting.Tests; + +/// +/// ScriptExecutionService 集成测试(spec §13 验收项映射)。 +/// 使用真实 InMemory ScriptingDbContext + Mock ISender/ICurrentUserContext,覆盖: +/// 脚本执行返回结果、变量读取、沙箱拦截(File/Reflection 禁用 API)、草稿可测、 +/// 状态守卫(Draft/Disabled 拒绝正式执行)、执行日志写入(success/timeout/failed)、 +/// 编译缓存、Logger 输出收集、按名执行。 +/// +public class ScriptExecutionServiceTests +{ + // ── 夹具 ─────────────────────────────────────────────────────────────── + + private static (ScriptingDbContext db, ScriptExecutionService svc) CreateService(ISender? mediator = null) + { + var db = ScriptingTestDbFactory.Create(); + var sender = mediator ?? Substitute.For(); + var currentUser = Substitute.For(); + currentUser.UserId.Returns("test-user"); + currentUser.IPAddress.Returns("127.0.0.1"); + var svc = new ScriptExecutionService(db, sender, currentUser); + return (db, svc); + } + + private static ScriptFunction SeedScript( + ScriptingDbContext db, + string code, + ScriptStatus status = ScriptStatus.Published, + string name = "test-script", + int timeoutMs = 5000) + { + var script = new ScriptFunction + { + Id = Guid.NewGuid(), + Name = name, + DisplayName = name, + Code = code, + Status = status, + TimeoutMs = timeoutMs, + CreatedBy = "test", + UpdatedBy = "test", + OperatorIP = "127.0.0.1", + }; + db.Scripts.Add(script); + db.SaveChanges(); + return script; + } + + // ── A1: 简单脚本返回结果 ────────────────────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_SimpleReturnScript_ReturnsSerializedResult() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 42;"); + await using var _ = db; + + var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + result.Status.Should().Be(ScriptExecutionStatus.Success); + result.ResultJson.Should().Be("42"); + } + + // ── A2: 脚本读取 Variables(原生类型) ───────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_ScriptReadsVariable_ReturnsComputedResult() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return (long)Variables[\"x\"] + (long)Variables[\"y\"];"); + await using var _ = db; + + var result = await svc.ExecuteByIdAsync(script.Id, """{"x":3,"y":4}""", CancellationToken.None); + + result.Status.Should().Be(ScriptExecutionStatus.Success); + result.ResultJson.Should().Be("7"); + } + + // ── §13 #4: SANDBOX — 禁用 API(File)被拦截 ─────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_ForbiddenFileApi_ThrowsBusinessException() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, """System.IO.File.ReadAllText("/etc/passwd"); return 1;"""); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*禁用*") + .Which.Message.Should().Contain("File"); + } + + // ── §13 #4: SANDBOX — Reflection 被拦截 ──────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_ReflectionApi_ThrowsBusinessException() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "var a = System.Reflection.Assembly.GetExecutingAssembly(); return 1;"); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*禁用*"); + } + + // ── §13 #5: 草稿可测(ExecuteTestAsync 跳过 Published 校验) ─────────── + + [Fact] + public async Task ExecuteTestAsync_DraftScript_ExecutesSuccessfully() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 42;", status: ScriptStatus.Draft); + await using var _ = db; + + var result = await svc.ExecuteTestAsync(script.Id, "{}", CancellationToken.None); + + result.Status.Should().Be(ScriptExecutionStatus.Success); + result.ResultJson.Should().Be("42"); + // 测试执行日志的 FunctionName 应标注 [TEST] + (await db.ScriptExecutionLogs.FirstAsync()).FunctionName.Should().Be("[TEST] test-script"); + } + + // ── 状态守卫:ExecuteByIdAsync 拒绝 Draft ────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_DraftScript_ThrowsNotPublished() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 1;", status: ScriptStatus.Draft); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*尚未发布*"); + } + + // ── 状态守卫:ExecuteByIdAsync 拒绝 Disabled ─────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_DisabledScript_ThrowsDisabled() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 1;", status: ScriptStatus.Disabled); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*已禁用*"); + } + + // ── §13 #10: 成功执行写入执行日志 ───────────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_Success_WritesExecutionLog() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 42;"); + await using var _ = db; + + await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + var logs = await db.ScriptExecutionLogs.ToListAsync(); + logs.Should().HaveCount(1); + logs[0].Status.Should().Be(ScriptExecutionStatus.Success); + logs[0].FunctionId.Should().Be(script.Id); + logs[0].ResultJson.Should().Be("42"); + logs[0].ErrorMessage.Should().BeNull(); + } + + // ── §13 #10: 超时执行写入 Timeout 日志 ──────────────────────────────── + // 注:CSharpScript 的取消是协作式的(仅在 await 检查点响应令牌)。 + // 真实长耗时脚本会用 Task.Delay(CancellationToken) 在 await 处被中断, + // 因此这里用 Task.Delay 而非 while(true){} 自旋(自旋不检查令牌会卡死)。 + + [Fact] + public async Task ExecuteByIdAsync_Timeout_WritesTimeoutLog() + { + var (db, svc) = CreateService(); + // 脚本等待 10s,但超时设为 100ms,必定先超时 + var script = SeedScript(db, "await Task.Delay(10000, CancellationToken); return 1;", timeoutMs: 100); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*超时*"); + var logs = await db.ScriptExecutionLogs.ToListAsync(); + logs.Should().HaveCount(1); + logs[0].Status.Should().Be(ScriptExecutionStatus.Timeout); + logs[0].ErrorMessage.Should().Contain("超时"); + } + + // ── 脚本运行时异常写入 Failed 日志 ──────────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_RuntimeError_WritesFailedLog() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, """throw new System.Exception("boom");"""); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + await act.Should().ThrowAsync(); + var logs = await db.ScriptExecutionLogs.ToListAsync(); + logs.Should().HaveCount(1); + logs[0].Status.Should().Be(ScriptExecutionStatus.Failed); + logs[0].ErrorMessage.Should().Contain("boom"); + } + + // ── 按名执行 ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteByNameAsync_PublishedScript_ReturnsResult() + { + var (db, svc) = CreateService(); + SeedScript(db, "return 99;", name: "by-name-fn"); + await using var _ = db; + + var result = await svc.ExecuteByNameAsync("by-name-fn", "{}", CancellationToken.None); + + result.Status.Should().Be(ScriptExecutionStatus.Success); + result.ResultJson.Should().Be("99"); + } + + [Fact] + public async Task ExecuteByNameAsync_NotFound_ThrowsBusinessException() + { + var (db, svc) = CreateService(); + await using var _ = db; + + var act = () => svc.ExecuteByNameAsync("no-such-script", "{}", CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*不存在*"); + } + + // ── §13 #8: 编译缓存(同代码两次执行都成功) ─────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_SameCodeTwice_BothSucceed() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, "return 1 + 1;"); + await using var _ = db; + + var r1 = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + var r2 = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + r1.Status.Should().Be(ScriptExecutionStatus.Success); + r2.Status.Should().Be(ScriptExecutionStatus.Success); + r1.ResultJson.Should().Be("2"); + r2.ResultJson.Should().Be("2"); + } + + // ── Logger 输出收集 ──────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteByIdAsync_ScriptLogs_ReturnsLogLines() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, """Logger.Info("hello-from-script"); return 1;"""); + await using var _ = db; + + var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + result.LogLines.Should().NotBeNull(); + result.LogLines.Should().Contain("hello-from-script"); + } + + // ── §13 #3: 业务异常(如权限拒绝)直接冒泡不包裹 ────────────────────── + // 模拟 MediatR handler 抛出权限拒绝类业务异常的场景:脚本里直接抛 BusinessException, + // 验证 ScriptExecutionService 的 catch(BusinessException) 分支直接 rethrow 而非包裹成"脚本执行失败"。 + + [Fact] + public async Task ExecuteByIdAsync_ScriptThrowsBusinessException_BubblesUpUnwrapped() + { + var (db, svc) = CreateService(); + var script = SeedScript(db, + """throw new BuildFlow.Shared.Domain.Exceptions.BusinessException("权限不足");"""); + await using var _ = db; + + var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("权限不足"); + // 业务异常路径仍应写 Failed 日志 + (await db.ScriptExecutionLogs.CountAsync()).Should().Be(1); + } + + // ── 按名执行:草稿拒绝(requirePublished 守卫) ──────────────────────── + + [Fact] + public async Task ExecuteByNameAsync_DraftScript_ThrowsNotPublished() + { + var (db, svc) = CreateService(); + SeedScript(db, "return 1;", status: ScriptStatus.Draft, name: "draft-fn"); + await using var _ = db; + + var act = () => svc.ExecuteByNameAsync("draft-fn", "{}", CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*尚未发布*"); + } +} diff --git a/tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs b/tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs new file mode 100644 index 0000000..8eddd62 --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs @@ -0,0 +1,28 @@ +using BuildFlow.Shared.Domain.Common; +using Microsoft.EntityFrameworkCore; +using Scripting.Infrastructure.Persistence; + +namespace BuildFlow.Scripting.Tests; + +/// +/// InMemory ScriptingDbContext 工厂(镜像 Workflow.Tests.TestDbFactory)。 +/// 传入测试用 ICurrentUserContext,让 ScriptingDbContext.OnConfiguring 注册 AuditInterceptor, +/// 自动填充审计字段(CreatedBy/UpdatedBy/OperatorIP 等),与生产行为一致。 +/// +public static class ScriptingTestDbFactory +{ + private sealed class TestCurrentUserContext : ICurrentUserContext + { + public string UserId => "test-user"; + public string IPAddress => "127.0.0.1"; + } + + public static ScriptingDbContext Create(ICurrentUserContext? currentUser = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"scripting-test-{Guid.NewGuid()}") + .Options; + // 用 2 参构造激活 AuditInterceptor,保证 ScriptExecutionLog 等实体的审计字段被自动填充 + return new ScriptingDbContext(options, currentUser ?? new TestCurrentUserContext()); + } +} diff --git a/tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj b/tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj index fb3d9fe..3218229 100644 --- a/tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj +++ b/tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs b/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs index 665d7eb..cb67846 100644 --- a/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs +++ b/tests/BuildFlow.Workflow.Tests/BuildFlow/BuildFlowButtonActionTests.cs @@ -1,8 +1,15 @@ 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; @@ -84,9 +91,116 @@ public class BuildFlowButtonActionTests .Should().Be(BuildFlowMetadataStatus.Disabled); } - // 注:原 7 个 BuildFlowButtonActionExecutor 执行器用例(runFunction/submitForm/权限校验等)已删除。 - // T6 已将执行器改造为依赖 IScriptExecutionService + ScriptingDbContext,旧用例基于已删除的 - // CustomFunctionExecutionService/CustomFunctionCommandHandler 链路,无法编译。T9 将基于新的脚本执行路径重写。 + // ── 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 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(); + execService.ExecuteByIdAsync( + scriptId, + Arg.Is(j => j.Contains("\"amount\":100")), + Arg.Any()) + .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(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() @@ -107,4 +221,20 @@ public class BuildFlowButtonActionTests ] """, }; + + /// 新建带审计模拟的 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"; + } }