using BuildFlow.Shared.Domain.Exceptions; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Scripting.Application.Commands; using Scripting.Application.Handlers; using Scripting.Domain.Entities.ScriptFunction; using Scripting.Domain.Enums; using Scripting.Infrastructure.Persistence; using Xunit; namespace BuildFlow.Scripting.Tests; /// /// 脚本 CQRS 命令处理器测试(spec §13 #6 可编辑性、生命周期守卫)。 /// 用真实 InMemory ScriptingDbContext 直接实例化 handler,覆盖: /// Create(唯一名校验 + 默认超时)、Update(Disabled 拒绝 + Published 自增版本)、 /// Delete(Published 拒绝 + Draft 软删除)、Publish(状态 + 版本)。 /// 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().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(ScriptStatusEnum.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: ScriptStatusEnum.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().WithMessage("*不能编辑*"); } [Fact] public async Task UpdateScript_Published_IncrementsVersion() { await using var db = ScriptingTestDbFactory.Create(); var script = await SeedAsync(db, name: "pub-fn", status: ScriptStatusEnum.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(ScriptStatusEnum.Published); // 保持已发布 } [Fact] public async Task UpdateScript_Draft_KeepsVersionAtOne() { await using var db = ScriptingTestDbFactory.Create(); var script = await SeedAsync(db, name: "draft-fn", status: ScriptStatusEnum.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: ScriptStatusEnum.Published); var handler = new DeleteScriptCommandHandler(db); var act = () => handler.Handle(new DeleteScriptCommand(script.Id), CancellationToken.None); await act.Should().ThrowAsync().WithMessage("*不能删除*"); } [Fact] public async Task DeleteScript_Draft_SoftDeletes() { await using var db = ScriptingTestDbFactory.Create(); var script = await SeedAsync(db, name: "del-draft", status: ScriptStatusEnum.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: ScriptStatusEnum.Draft, version: 1); var handler = new PublishScriptCommandHandler(db); var dto = await handler.Handle(new PublishScriptCommand(script.Id), CancellationToken.None); dto.Status.Should().Be(ScriptStatusEnum.Published); dto.Version.Should().Be(2); } // ── 夹具 ─────────────────────────────────────────────────────────────── private static async Task SeedAsync( ScriptingDbContext db, string name, ScriptStatusEnum status = ScriptStatusEnum.Draft, int version = 1) { var script = new ScriptFunctionEntity { 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; } }