361 lines
12 KiB
C#
361 lines
12 KiB
C#
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<WorkflowDbContext>()
|
||
.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<BusinessException>();
|
||
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
|
||
}
|