- 从文件模块的多部分上传处理器中移除未使用的实体引用 - 从即时通讯模块的群组成员处理器中移除未使用的实体引用 - 从RAG模块的AI模型配置中移除未使用的实体引用 - 从监控模块的告警规则配置中移除未使用的实体引用 - 从平台模块的角色权限处理器中移除未使用的实体引用 - 从脚本模块的执行日志实体引用中进行清理 - 从多个基础设施配置类中移除未使用的依赖项 - 优化测试文件中的导入声明,移除不必要的引用
319 lines
13 KiB
C#
319 lines
13 KiB
C#
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.ScriptFunction;
|
||
using Scripting.Domain.Enums;
|
||
using Scripting.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
namespace BuildFlow.Scripting.Tests;
|
||
|
||
/// <summary>
|
||
/// ScriptExecutionService 集成测试(spec §13 验收项映射)。
|
||
/// 使用真实 InMemory ScriptingDbContext + Mock ISender/ICurrentUserContext,覆盖:
|
||
/// 脚本执行返回结果、变量读取、沙箱拦截(File/Reflection 禁用 API)、草稿可测、
|
||
/// 状态守卫(Draft/Disabled 拒绝正式执行)、执行日志写入(success/timeout/failed)、
|
||
/// 编译缓存、Logger 输出收集、按名执行。
|
||
/// </summary>
|
||
public class ScriptExecutionServiceTests
|
||
{
|
||
// ── 夹具 ───────────────────────────────────────────────────────────────
|
||
|
||
private static (ScriptingDbContext db, ScriptExecutionService svc) CreateService(ISender? mediator = null)
|
||
{
|
||
var db = ScriptingTestDbFactory.Create();
|
||
var sender = mediator ?? Substitute.For<ISender>();
|
||
var currentUser = Substitute.For<ICurrentUserContext>();
|
||
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<BusinessException>())
|
||
.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<BusinessException>())
|
||
.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<BusinessException>().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<BusinessException>().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<BusinessException>().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<BusinessException>();
|
||
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<BusinessException>().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<BusinessException>())
|
||
.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<BusinessException>().WithMessage("*尚未发布*");
|
||
}
|
||
}
|