using BuildFlow.Shared.Domain.Errors; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Workflow.Application.Features.WorkflowDefinitions.Commands; using Workflow.Application.Features.WorkflowDefinitions.Handlers; using Workflow.Application.Features.WorkflowDefinitions.Queries; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Xunit; namespace BuildFlow.Workflow.Tests.Handlers; public class WorkflowDefinitionHandlerTests { private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var db = new WorkflowDbContext(options); db.SavingChanges += AuditStub.OnSave; return db; } #region CreateWorkflowDefinition [Fact] public async Task CreateDefinition_SavesToDatabase() { // Arrange await using var db = CreateDbContext(); var handler = new CreateWorkflowDefinitionCommandHandler(db); var command = new CreateWorkflowDefinitionCommand( Name: "Leave Approval", Code: "leave-approval", Description: "Standard leave approval workflow", FormDefinitionId: null ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Leave Approval"); result.Code.Should().Be("leave-approval"); result.Description.Should().Be("Standard leave approval workflow"); result.Status.Should().Be(DefinitionStatus.Draft); result.Version.Should().Be(1); var entity = await db.WorkflowDefinitions.FirstOrDefaultAsync(d => d.Code == "leave-approval"); entity.Should().NotBeNull(); entity!.Name.Should().Be("Leave Approval"); } [Fact] public async Task CreateDefinition_WithDuplicateCode_ThrowsBusinessException() { // Arrange await using var db = CreateDbContext(); var existing = new WorkflowDefinition { Id = Guid.NewGuid(), Name = "Existing", Code = "duplicate-code", Status = DefinitionStatus.Draft, Version = 1 }; db.WorkflowDefinitions.Add(existing); await db.SaveChangesAsync(); var handler = new CreateWorkflowDefinitionCommandHandler(db); var command = new CreateWorkflowDefinitionCommand( Name: "New Workflow", Code: "duplicate-code", Description: "Should fail", FormDefinitionId: null ); // Act var act = () => handler.Handle(command, CancellationToken.None); // Assert var exception = await act.Should().ThrowAsync(); exception.WithMessage("*duplicate-code*"); exception.Which.Code.Should().Be(ErrorCode.AlreadyExists); } #endregion #region UpdateWorkflowDefinition [Fact] public async Task UpdateDefinition_UpdatesFields() { // Arrange await using var db = CreateDbContext(); var definitionId = Guid.NewGuid(); var existing = new WorkflowDefinition { Id = definitionId, Name = "Old Name", Code = "test-workflow", Description = "Old description", Status = DefinitionStatus.Draft, Version = 1 }; db.WorkflowDefinitions.Add(existing); await db.SaveChangesAsync(); var handler = new UpdateWorkflowDefinitionCommandHandler(db); var command = new UpdateWorkflowDefinitionCommand( Id: definitionId, Name: "Updated Name", Description: "Updated description", DefinitionJson: "{\"nodes\":[],\"edges\":[]}", DefinitionTree: null, FormDefinitionId: null ); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Updated Name"); result.Description.Should().Be("Updated description"); result.DefinitionJson.Should().Be("{\"nodes\":[],\"edges\":[]}"); var entity = await db.WorkflowDefinitions.FindAsync(definitionId); entity.Should().NotBeNull(); entity!.Name.Should().Be("Updated Name"); } #endregion #region DeleteWorkflowDefinition [Fact] public async Task DeleteDefinition_SoftDeletes() { // Arrange await using var db = CreateDbContext(); var definitionId = Guid.NewGuid(); var existing = new WorkflowDefinition { Id = definitionId, Name = "To Delete", Code = "to-delete", Status = DefinitionStatus.Draft, Version = 1 }; db.WorkflowDefinitions.Add(existing); await db.SaveChangesAsync(); var handler = new DeleteWorkflowDefinitionCommandHandler(db); var command = new DeleteWorkflowDefinitionCommand(Id: definitionId); // Act await handler.Handle(command, CancellationToken.None); // Assert - soft delete means the entity still exists but IsDeleted = true var entity = await db.WorkflowDefinitions .IgnoreQueryFilters() .FirstOrDefaultAsync(d => d.Id == definitionId); entity.Should().NotBeNull(); entity!.IsDeleted.Should().BeTrue(); // Normal query should not return the deleted entity // Note: InMemory provider does not support global query filters, // so FindAsync still returns the entity. Verify via filtered query instead. var activeEntity = await db.WorkflowDefinitions .FirstOrDefaultAsync(d => d.Id == definitionId); activeEntity?.IsDeleted.Should().BeTrue(); } #endregion #region PublishWorkflowDefinition [Fact] public async Task PublishDefinition_ChangesStatusAndIncrementsVersion() { // Arrange await using var db = CreateDbContext(); var definitionId = Guid.NewGuid(); // 发布前需有合法的 X6 graph JSON(含 start + end + 连通边),否则图校验拦截 var startId = Guid.NewGuid(); var endId = Guid.NewGuid(); var edgeId = Guid.NewGuid(); var definitionJson = "{\"nodes\":[" + "{\"id\":\"" + startId + "\",\"shape\":\"wf-start\",\"x\":0,\"y\":0,\"data\":{\"title\":\"开始\"}}," + "{\"id\":\"" + endId + "\",\"shape\":\"wf-end\",\"x\":0,\"y\":200,\"data\":{\"title\":\"结束\"}}" + "],\"edges\":[" + "{\"id\":\"" + edgeId + "\",\"shape\":\"wf-edge\"," + "\"source\":{\"cell\":\"" + startId + "\",\"port\":\"bottom\"}," + "\"target\":{\"cell\":\"" + endId + "\",\"port\":\"top\"}," + "\"data\":{\"edgeType\":0}}" + "]}"; var existing = new WorkflowDefinition { Id = definitionId, Name = "Publishable Workflow", Code = "publishable", Status = DefinitionStatus.Draft, Version = 1, DefinitionJson = definitionJson }; db.WorkflowDefinitions.Add(existing); await db.SaveChangesAsync(); var handler = new PublishWorkflowDefinitionCommandHandler(db); var command = new PublishWorkflowDefinitionCommand(Id: definitionId); // Act await handler.Handle(command, CancellationToken.None); // Assert var entity = await db.WorkflowDefinitions.FindAsync(definitionId); entity.Should().NotBeNull(); entity!.Status.Should().Be(DefinitionStatus.Published); entity.Version.Should().Be(2); // 发布应写入不可变快照(版本隔离) var snapshot = await db.WorkflowDefinitionSnapshots .FirstOrDefaultAsync(s => s.DefinitionId == definitionId && s.Version == 2); snapshot.Should().NotBeNull(); snapshot!.DefinitionJson.Should().Be(definitionJson); } #endregion #region GetWorkflowDefinitionList [Fact] public async Task GetDefinitionList_ReturnsPaged() { // Arrange await using var db = CreateDbContext(); for (int i = 1; i <= 15; i++) { db.WorkflowDefinitions.Add(new WorkflowDefinition { Id = Guid.NewGuid(), Name = $"Workflow {i}", Code = $"workflow-{i}", Status = DefinitionStatus.Draft, Version = 1 }); } await db.SaveChangesAsync(); var handler = new GetWorkflowDefinitionListQueryHandler(db); var query = new GetWorkflowDefinitionListQuery( PageIndex: 1, PageSize: 10, Status: null ); // Act var result = await handler.Handle(query, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Items.Should().HaveCount(10); result.Total.Should().Be(15); result.PageIndex.Should().Be(1); result.PageSize.Should().Be(10); } [Fact] public async Task GetDefinitionList_FiltersByStatus() { // Arrange await using var db = CreateDbContext(); db.WorkflowDefinitions.Add(new WorkflowDefinition { Id = Guid.NewGuid(), Name = "Draft Workflow", Code = "draft-wf", Status = DefinitionStatus.Draft, Version = 1 }); db.WorkflowDefinitions.Add(new WorkflowDefinition { Id = Guid.NewGuid(), Name = "Published Workflow", Code = "published-wf", Status = DefinitionStatus.Published, Version = 2 }); await db.SaveChangesAsync(); var handler = new GetWorkflowDefinitionListQueryHandler(db); var query = new GetWorkflowDefinitionListQuery( PageIndex: 1, PageSize: 10, Status: DefinitionStatus.Published ); // Act var result = await handler.Handle(query, CancellationToken.None); // Assert result.Should().NotBeNull(); result.Items.Should().HaveCount(1); result.Items[0].Status.Should().Be(DefinitionStatus.Published); result.Total.Should().Be(1); } #endregion #region GetWorkflowDefinitionById [Fact] public async Task GetDefinitionById_ReturnsJsonAsSourceOfTruth() { // Arrange:JSON 单一事实源下,节点/边数据在 DefinitionJson,Nodes/Edges 集合置空 await using var db = CreateDbContext(); var definitionId = Guid.NewGuid(); var startId = Guid.NewGuid(); var endId = Guid.NewGuid(); var definitionJson = "{\"nodes\":[" + "{\"id\":\"" + startId + "\",\"shape\":\"wf-start\",\"x\":100,\"y\":100,\"data\":{\"title\":\"Start\"}}," + "{\"id\":\"" + endId + "\",\"shape\":\"wf-end\",\"x\":500,\"y\":100,\"data\":{\"title\":\"End\"}}" + "],\"edges\":[" + "{\"id\":\"" + Guid.NewGuid() + "\",\"shape\":\"wf-edge\"," + "\"source\":{\"cell\":\"" + startId + "\",\"port\":\"bottom\"}," + "\"target\":{\"cell\":\"" + endId + "\",\"port\":\"top\"}," + "\"data\":{\"edgeType\":0,\"label\":\"to end\"}}" + "]}"; var definition = new WorkflowDefinition { Id = definitionId, Name = "Workflow With Graph", Code = "graph-wf", Status = DefinitionStatus.Published, Version = 1, DefinitionJson = definitionJson }; db.WorkflowDefinitions.Add(definition); await db.SaveChangesAsync(); var handler = new GetWorkflowDefinitionByIdQueryHandler(db); var query = new GetWorkflowDefinitionByIdQuery(Id: definitionId); // Act var result = await handler.Handle(query, CancellationToken.None); // Assert:JSON 是权威源,集合为空(P4 清理时一并移除) result.Should().NotBeNull(); result.Id.Should().Be(definitionId); result.DefinitionJson.Should().Be(definitionJson); result.Nodes.Should().NotBeNull().And.BeEmpty(); result.Edges.Should().NotBeNull().And.BeEmpty(); } #endregion }