build-flow.api/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs
xiangning 3b13099378 refactor(domain): 重构领域实体结构并更新枚举命名
- 将通用 Entities 命名空间拆分为具体业务实体子目录
- 更新所有实体类型引用为具体的 Entity 后缀类型
- 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀
- 重构 AiModel 相关配置和映射逻辑适配新的实体结构
- 更新测试代码中的实体类型引用和枚举使用
- 调整应用层处理器中的实体创建和查询逻辑
- 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
2026-07-19 14:02:32 +08:00

320 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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