test(scripting): 集成测试 + 按钮链回归测试
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)
This commit is contained in:
parent
2d70d92522
commit
e9cc9247f2
@ -12,8 +12,11 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\modules\scripting\Application\Scripting.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\modules\scripting\Infrastructure\Scripting.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
184
tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs
Normal file
184
tests/BuildFlow.Scripting.Tests/ScriptCommandHandlerTests.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 脚本 CQRS 命令处理器测试(spec §13 #6 可编辑性、生命周期守卫)。
|
||||
/// 用真实 InMemory ScriptingDbContext 直接实例化 handler,覆盖:
|
||||
/// Create(唯一名校验 + 默认超时)、Update(Disabled 拒绝 + Published 自增版本)、
|
||||
/// Delete(Published 拒绝 + Draft 软删除)、Publish(状态 + 版本)。
|
||||
/// </summary>
|
||||
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<BusinessException>().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<BusinessException>().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<BusinessException>().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<ScriptFunction> 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;
|
||||
}
|
||||
}
|
||||
318
tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs
Normal file
318
tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceTests.cs
Normal file
@ -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;
|
||||
|
||||
/// <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 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<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: 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<BusinessException>().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<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(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<BusinessException>().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<BusinessException>();
|
||||
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<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(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<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: ScriptStatus.Draft, name: "draft-fn");
|
||||
await using var _ = db;
|
||||
|
||||
var act = () => svc.ExecuteByNameAsync("draft-fn", "{}", CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>().WithMessage("*尚未发布*");
|
||||
}
|
||||
}
|
||||
28
tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs
Normal file
28
tests/BuildFlow.Scripting.Tests/ScriptingTestDbFactory.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scripting.Infrastructure.Persistence;
|
||||
|
||||
namespace BuildFlow.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InMemory ScriptingDbContext 工厂(镜像 Workflow.Tests.TestDbFactory)。
|
||||
/// 传入测试用 ICurrentUserContext,让 ScriptingDbContext.OnConfiguring 注册 AuditInterceptor,
|
||||
/// 自动填充审计字段(CreatedBy/UpdatedBy/OperatorIP 等),与生产行为一致。
|
||||
/// </summary>
|
||||
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<ScriptingDbContext>()
|
||||
.UseInMemoryDatabase($"scripting-test-{Guid.NewGuid()}")
|
||||
.Options;
|
||||
// 用 2 参构造激活 AuditInterceptor,保证 ScriptExecutionLog 等实体的审计字段被自动填充
|
||||
return new ScriptingDbContext(options, currentUser ?? new TestCurrentUserContext());
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\modules\workflow\Application\Workflow.Application.csproj" />
|
||||
|
||||
@ -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 ───
|
||||
|
||||
/// <summary>
|
||||
/// 回归测试(spec §13 #7 按钮动作链):runFunction 动作通过 IScriptExecutionService 执行脚本。
|
||||
/// 验证 T6 的 rewiring 后执行器正确委托给新的脚本执行服务,且返回成功结果。
|
||||
/// </summary>
|
||||
[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<IScriptExecutionService>();
|
||||
execService.ExecuteByIdAsync(
|
||||
scriptId,
|
||||
Arg.Is<string>(j => j.Contains("\"amount\":100")),
|
||||
Arg.Any<CancellationToken>())
|
||||
.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<string>(j => j.Contains("\"amount\":100")),
|
||||
Arg.Any<CancellationToken>());
|
||||
// 整体执行日志应写入 WorkflowDbContext
|
||||
(await wfDb.BuildFlowActionExecutionLogs.CountAsync()).Should().Be(1);
|
||||
}
|
||||
|
||||
/// <summary>未发布按钮不可执行(状态守卫)。</summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DraftButton_ThrowsBusinessException()
|
||||
{
|
||||
await using var wfDb = TestDbFactory.Create();
|
||||
await using var scriptingDb = CreateScriptingDb();
|
||||
var execService = Substitute.For<IScriptExecutionService>();
|
||||
|
||||
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<BusinessException>().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
|
||||
]
|
||||
""",
|
||||
};
|
||||
|
||||
/// <summary>新建带审计模拟的 InMemory ScriptingDbContext(镜像 Scripting.Tests 模式)。</summary>
|
||||
private static ScriptingDbContext CreateScriptingDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScriptingDbContext>()
|
||||
.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";
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user