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.ScriptExecutionLog; using Scripting.Domain.Entities.ScriptFunction; 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 ScriptFunctionEntity SeedScript( ScriptingDbContext db, string code, ScriptStatusEnum status = ScriptStatusEnum.Published, string name = "test-script", int timeoutMs = 5000) { var script = new ScriptFunctionEntity { 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(ScriptExecutionStatusEnum.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(ScriptExecutionStatusEnum.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: ScriptStatusEnum.Draft); await using var _ = db; var result = await svc.ExecuteTestAsync(script.Id, "{}", CancellationToken.None); result.Status.Should().Be(ScriptExecutionStatusEnum.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: ScriptStatusEnum.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: ScriptStatusEnum.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(ScriptExecutionStatusEnum.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(ScriptExecutionStatusEnum.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(ScriptExecutionStatusEnum.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(ScriptExecutionStatusEnum.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(ScriptExecutionStatusEnum.Success); r2.Status.Should().Be(ScriptExecutionStatusEnum.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: ScriptStatusEnum.Draft, name: "draft-fn"); await using var _ = db; var act = () => svc.ExecuteByNameAsync("draft-fn", "{}", CancellationToken.None); await act.Should().ThrowAsync().WithMessage("*尚未发布*"); } }