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("*尚未发布*");
}
}