build-flow.api/tests/BuildFlow.Workflow.Tests/Handlers/WorkflowDefinitionHandlerTests.cs
2026-07-24 12:47:09 +08:00

361 lines
12 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.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
}