- Add FormDefinitionVersion with compare/versions endpoints and schema differ - Add Notification entity, endpoints and application features - Add Scheduler (timeout) and WebhookDispatcher services - Add FormDataValidator/FieldPermissionEvaluator/ReactionEvaluator - Add workflow task mark-read, CC support and SystemUserContext - Add EF migrations for form versions and notifications - Add unit tests for form schema, notifications, scheduler and serialization
1061 lines
35 KiB
C#
1061 lines
35 KiB
C#
using FluentAssertions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using System.Text.Json.Nodes;
|
||
using Workflow.Application.Engine;
|
||
using Workflow.Application.Features.WorkflowTasks.Commands;
|
||
using Workflow.Application.Features.WorkflowTasks.Queries;
|
||
using Workflow.Domain.Entities;
|
||
using Workflow.Domain.Enums;
|
||
using Workflow.Domain.Exceptions;
|
||
using Workflow.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
using TaskStatus = Workflow.Domain.Enums.TaskStatus;
|
||
|
||
namespace Workflow.Tests.Handlers;
|
||
|
||
public class WorkflowTaskHandlerTests
|
||
{
|
||
private const string ApprovalFormSchema = """
|
||
{
|
||
"type": "object",
|
||
"required": ["approvalScore"],
|
||
"properties": {
|
||
"approvalScore": {
|
||
"type": "number",
|
||
"title": "审批分",
|
||
"x-component": "InputNumber"
|
||
}
|
||
}
|
||
}
|
||
""";
|
||
|
||
private static WorkflowDbContext CreateDbContext()
|
||
{
|
||
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
return new WorkflowDbContext(options);
|
||
}
|
||
|
||
private static async Task<(WorkflowDbContext db, Guid instanceId, Guid tokenId, Guid nodeId)> SeedTaskWithWorkflowAsync(
|
||
Guid taskId, Guid assigneeId, Guid? tokenId = null, Guid? nodeId = null)
|
||
{
|
||
var db = CreateDbContext();
|
||
var definitionId = Guid.NewGuid();
|
||
var instId = Guid.NewGuid();
|
||
var tokId = tokenId ?? Guid.NewGuid();
|
||
var ndId = nodeId ?? Guid.NewGuid();
|
||
var endNodeId = Guid.NewGuid();
|
||
|
||
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
||
{
|
||
Id = definitionId, Name = "Test", Code = "test",
|
||
Status = DefinitionStatus.Published, IsEnabled = true
|
||
});
|
||
db.WorkflowInstances.Add(new WorkflowInstance
|
||
{
|
||
Id = instId, DefinitionId = definitionId,
|
||
Status = InstanceStatus.Running
|
||
});
|
||
db.WorkflowTokens.Add(new WorkflowToken
|
||
{
|
||
Id = tokId, InstanceId = instId, NodeId = ndId, Status = TokenStatus.Active
|
||
});
|
||
db.WorkflowNodes.Add(new WorkflowNode
|
||
{
|
||
Id = ndId, DefinitionId = definitionId, NodeType = NodeType.Approval, Name = "Approve"
|
||
});
|
||
db.WorkflowNodes.Add(new WorkflowNode
|
||
{
|
||
Id = endNodeId, DefinitionId = definitionId, NodeType = NodeType.End, Name = "End"
|
||
});
|
||
db.WorkflowEdges.Add(new WorkflowEdge
|
||
{
|
||
Id = Guid.NewGuid(), DefinitionId = definitionId,
|
||
SourceNodeId = ndId, TargetNodeId = endNodeId, EdgeType = EdgeType.Approved
|
||
});
|
||
db.WorkflowEdges.Add(new WorkflowEdge
|
||
{
|
||
Id = Guid.NewGuid(), DefinitionId = definitionId,
|
||
SourceNodeId = ndId, TargetNodeId = endNodeId, EdgeType = EdgeType.Rejected
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId, InstanceId = instId, TokenId = tokId,
|
||
NodeId = ndId, AssigneeId = assigneeId, Status = TaskStatus.Pending
|
||
});
|
||
|
||
await db.SaveChangesAsync();
|
||
return (db, instId, tokId, ndId);
|
||
}
|
||
|
||
#region ApproveTask
|
||
|
||
[Fact]
|
||
public async Task ApproveTask_UpdatesTaskStatus()
|
||
{
|
||
// Arrange
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var (db, _, _, _) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "Looks good"
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updated = await db.WorkflowTasks.FindAsync(taskId);
|
||
updated.Should().NotBeNull();
|
||
updated!.Status.Should().Be(TaskStatus.Approved);
|
||
updated.Result.Should().Be("\"approved\"");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ApproveTask_SetsCompletedAt()
|
||
{
|
||
// Arrange
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var (db, _, _, _) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
var beforeApprove = DateTime.UtcNow;
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "Approved"
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updated = await db.WorkflowTasks.FindAsync(taskId);
|
||
updated.Should().NotBeNull();
|
||
updated!.CompletedAt.Should().NotBeNull();
|
||
updated.CompletedAt.Should().BeOnOrAfter(beforeApprove);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ApproveTask_ByNonAssignee_ThrowsUnauthorizedException()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var otherUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = assigneeId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: otherUserId,
|
||
Comment: "Impersonation attempt"
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<UnauthorizedException>()
|
||
.WithMessage("*assignee*");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region TaskDetail
|
||
|
||
[Fact]
|
||
public async Task GetTaskById_ReturnsNodeFormAndInstanceFormData()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var definitionId = Guid.NewGuid();
|
||
var startFormId = Guid.NewGuid();
|
||
var nodeFormId = Guid.NewGuid();
|
||
var instanceId = Guid.NewGuid();
|
||
var tokenId = Guid.NewGuid();
|
||
var nodeId = Guid.NewGuid();
|
||
var taskId = Guid.NewGuid();
|
||
|
||
db.FormDefinitions.AddRange(
|
||
new FormDefinition
|
||
{
|
||
Id = startFormId,
|
||
Name = "发起表单",
|
||
Code = "start-form",
|
||
SchemaJson = ApprovalFormSchema
|
||
},
|
||
new FormDefinition
|
||
{
|
||
Id = nodeFormId,
|
||
Name = "审批表单",
|
||
Code = "approval-form",
|
||
SchemaJson = ApprovalFormSchema
|
||
});
|
||
db.WorkflowDefinitions.Add(new WorkflowDefinition
|
||
{
|
||
Id = definitionId,
|
||
Name = "Test",
|
||
Code = "test",
|
||
FormDefinitionId = startFormId,
|
||
Status = DefinitionStatus.Published,
|
||
IsEnabled = true
|
||
});
|
||
db.WorkflowInstances.Add(new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = definitionId,
|
||
Status = InstanceStatus.Running
|
||
});
|
||
db.FormData.Add(new FormData
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
FormDefinitionId = startFormId,
|
||
InstanceId = instanceId,
|
||
DataJson = """{"amount":6500}"""
|
||
});
|
||
db.WorkflowTokens.Add(new WorkflowToken
|
||
{
|
||
Id = tokenId,
|
||
InstanceId = instanceId,
|
||
NodeId = nodeId,
|
||
Status = TokenStatus.Active
|
||
});
|
||
db.WorkflowNodes.Add(new WorkflowNode
|
||
{
|
||
Id = nodeId,
|
||
DefinitionId = definitionId,
|
||
NodeType = NodeType.Approval,
|
||
Name = "主管审批",
|
||
FormDefinitionId = nodeFormId
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = instanceId,
|
||
TokenId = tokenId,
|
||
NodeId = nodeId,
|
||
Status = TaskStatus.Pending,
|
||
Title = "报销审批"
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetTaskByIdQueryHandler(db);
|
||
|
||
// Act
|
||
var result = await handler.Handle(new GetTaskByIdQuery(taskId), CancellationToken.None);
|
||
|
||
// Assert
|
||
result.NodeId.Should().Be(nodeId);
|
||
result.NodeName.Should().Be("主管审批");
|
||
result.FormDefinitionId.Should().Be(nodeFormId);
|
||
result.FormName.Should().Be("审批表单");
|
||
result.FormSchemaJson.Should().Be(ApprovalFormSchema);
|
||
result.InstanceFormDataJson.Should().Be("""{"amount":6500}""");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region RejectTask
|
||
|
||
[Fact]
|
||
public async Task RejectTask_UpdatesTaskStatusWithComment()
|
||
{
|
||
// Arrange
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var (db, _, _, _) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
|
||
var handler = new RejectTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new RejectTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "Missing required documents"
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updated = await db.WorkflowTasks.FindAsync(taskId);
|
||
updated.Should().NotBeNull();
|
||
updated!.Status.Should().Be(TaskStatus.Rejected);
|
||
updated.Comment.Should().Be("Missing required documents");
|
||
updated.CompletedAt.Should().NotBeNull();
|
||
}
|
||
|
||
#endregion
|
||
|
||
[Fact]
|
||
public async Task ApproveTask_WithNodeFormData_SavesFormDataAndMergesInstanceVariables()
|
||
{
|
||
// Arrange
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var formId = Guid.NewGuid();
|
||
var (db, instanceId, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "审批表单",
|
||
Code = "approval-form",
|
||
SchemaJson = ApprovalFormSchema
|
||
});
|
||
|
||
var node = await db.WorkflowNodes.FindAsync(nodeId);
|
||
node!.FormDefinitionId = formId;
|
||
|
||
var instance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
instance!.Variables = """{"amount":6500}""";
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "Looks good",
|
||
FormDataJson: """{"approvalScore":98}"""
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var savedData = await db.FormData.SingleAsync(f => f.InstanceId == instanceId && f.FormDefinitionId == formId);
|
||
savedData.DataJson.Should().Be("""{"approvalScore":98}""");
|
||
|
||
var updatedInstance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
var variables = JsonNode.Parse(updatedInstance!.Variables!)!.AsObject();
|
||
variables["amount"]!.GetValue<int>().Should().Be(6500);
|
||
variables["approvalScore"]!.GetValue<int>().Should().Be(98);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:节点绑定的表单已被软删除时,审批提交表单必须给出准确错误信息,
|
||
/// 而不是误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。
|
||
/// 此时不可继续提交表单数据。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task ApproveTask_WithSoftDeletedFormDefinition_ThrowsAccurateError()
|
||
{
|
||
// Arrange
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var formId = Guid.NewGuid();
|
||
var (db, _, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
|
||
// 用 IgnoreQueryFilters 绕过全局软删除过滤器插入一张已删除的表单
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "已被删除的审批表单",
|
||
Code = "deleted-approval-form",
|
||
SchemaJson = ApprovalFormSchema,
|
||
IsDeleted = true,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var node = await db.WorkflowNodes.FindAsync(nodeId);
|
||
node!.FormDefinitionId = formId;
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "approve",
|
||
FormDataJson: """{"approvalScore":98}"""
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert:必须是明确、准确的错误,而非误导性的「不存在」
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*表单*删除*");
|
||
|
||
// 且不得写入任何表单数据
|
||
var savedData = await db.FormData.ToListAsync();
|
||
savedData.Should().BeEmpty();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:节点未绑定任何表单,却提交了表单数据 —— 当前行为是阻断并提示,
|
||
/// 此测试锁定该不变量,避免误把数据写到未关联的实体。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task ApproveTask_WithFormDataButNodeHasNoForm_ThrowsBusinessException()
|
||
{
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var (db, _, _, _) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "approve",
|
||
FormDataJson: """{"approvalScore":98}"""
|
||
);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*未绑定表单*");
|
||
|
||
var savedData = await db.FormData.ToListAsync();
|
||
savedData.Should().BeEmpty();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:节点绑定的表单状态为 Disabled 时,审批提交表单必须严格阻断。
|
||
/// 产品决策:Disabled = 严格阻断(审批提交一律拒绝)。且不得写入任何表单数据。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task ApproveTask_WithDisabledForm_ThrowsBusinessException()
|
||
{
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var formId = Guid.NewGuid();
|
||
var (db, _, _, nodeId) = await SeedTaskWithWorkflowAsync(taskId, assigneeId);
|
||
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "停用的审批表单",
|
||
Code = "disabled-approval-form",
|
||
SchemaJson = ApprovalFormSchema,
|
||
Status = FormStatus.Disabled,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var node = await db.WorkflowNodes.FindAsync(nodeId);
|
||
node!.FormDefinitionId = formId;
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new ApproveTaskCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new ApproveTaskCommand(
|
||
TaskId: taskId,
|
||
UserId: assigneeId,
|
||
Comment: "approve",
|
||
FormDataJson: """{"approvalScore":98}"""
|
||
);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*表单*停用*");
|
||
|
||
var savedData = await db.FormData.ToListAsync();
|
||
savedData.Should().BeEmpty();
|
||
}
|
||
|
||
#region MarkCcTaskRead
|
||
|
||
/// <summary>
|
||
/// 正常路径:Cc 任务可被其 assignee 标记为已读,状态变为 Read,CompletedAt 被记录。
|
||
/// 不涉及 token 路由(Cc 任务为知会性质)。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task MarkCcTaskRead_PendingCcTaskByAssignee_MarksRead()
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = assigneeId,
|
||
Type = TaskType.Cc,
|
||
Status = TaskStatus.Pending,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new MarkCcTaskReadCommandHandler(db);
|
||
await handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None);
|
||
|
||
var task = await db.WorkflowTasks.FindAsync(taskId);
|
||
task.Should().NotBeNull();
|
||
task!.Status.Should().Be(TaskStatus.Read);
|
||
task.CompletedAt.Should().NotBeNull();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:非 Cc 类型(审批任务)不可被标记已读——标记已读仅适用于知会类任务。
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(TaskType.Approval)]
|
||
public async Task MarkCcTaskRead_NonCcTask_ThrowsBusinessException(TaskType type)
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = assigneeId,
|
||
Type = type,
|
||
Status = TaskStatus.Pending,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new MarkCcTaskReadCommandHandler(db);
|
||
var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*Cc*");
|
||
|
||
var task = await db.WorkflowTasks.FindAsync(taskId);
|
||
task!.Status.Should().Be(TaskStatus.Pending);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:已读/已完结的 Cc 任务不可重复标记(幂等性/防重复)。
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(TaskStatus.Read)]
|
||
public async Task MarkCcTaskRead_AlreadyRead_ThrowsBusinessException(TaskStatus status)
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = assigneeId,
|
||
Type = TaskType.Cc,
|
||
Status = status,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new MarkCcTaskReadCommandHandler(db);
|
||
var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, assigneeId), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*已读*");
|
||
|
||
var task = await db.WorkflowTasks.FindAsync(taskId);
|
||
task!.Status.Should().Be(status);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:仅 assignee 可标记自己的 Cc 任务已读,非 assignee 被拒绝(防越权)。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task MarkCcTaskRead_ByNonAssignee_ThrowsUnauthorizedException()
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var assigneeId = Guid.NewGuid();
|
||
var otherUser = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = assigneeId,
|
||
Type = TaskType.Cc,
|
||
Status = TaskStatus.Pending,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new MarkCcTaskReadCommandHandler(db);
|
||
var act = () => handler.Handle(new MarkCcTaskReadCommand(taskId, otherUser), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||
|
||
var task = await db.WorkflowTasks.FindAsync(taskId);
|
||
task!.Status.Should().Be(TaskStatus.Pending);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:不存在的任务 ID 抛 NotFoundException。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task MarkCcTaskRead_NonExistentTask_ThrowsNotFoundException()
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var handler = new MarkCcTaskReadCommandHandler(db);
|
||
var act = () => handler.Handle(new MarkCcTaskReadCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None);
|
||
await act.Should().ThrowAsync<NotFoundException>();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region TransferTask
|
||
|
||
[Fact]
|
||
public async Task TransferTask_CreatesNewTaskForTargetUser()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
var instanceId = Guid.NewGuid();
|
||
var tokenId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = instanceId,
|
||
TokenId = tokenId,
|
||
AssigneeId = fromUserId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new TransferTaskCommandHandler(db);
|
||
var command = new TransferTaskCommand(
|
||
TaskId: taskId,
|
||
FromUserId: fromUserId,
|
||
ToUserId: toUserId
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert - a new task for the target user should exist
|
||
var newTask = await db.WorkflowTasks
|
||
.FirstOrDefaultAsync(t => t.AssigneeId == toUserId && t.Id != taskId);
|
||
newTask.Should().NotBeNull();
|
||
newTask!.Status.Should().Be(TaskStatus.Pending);
|
||
newTask.InstanceId.Should().Be(instanceId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TransferTask_ClosesOriginalTask()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = fromUserId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new TransferTaskCommandHandler(db);
|
||
var command = new TransferTaskCommand(
|
||
TaskId: taskId,
|
||
FromUserId: fromUserId,
|
||
ToUserId: toUserId
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original.Should().NotBeNull();
|
||
original!.Status.Should().Be(TaskStatus.Transferred);
|
||
original.CompletedAt.Should().NotBeNull();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:非 Pending 状态(已审批)的任务不可被转办,否则会重复推进流程。
|
||
/// 当前缺失状态校验——必须补上。
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(TaskStatus.Approved)]
|
||
[InlineData(TaskStatus.Rejected)]
|
||
[InlineData(TaskStatus.Transferred)]
|
||
[InlineData(TaskStatus.Delegated)]
|
||
public async Task TransferTask_NonPendingTask_ThrowsBusinessException(TaskStatus status)
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = fromUserId,
|
||
Status = status
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new TransferTaskCommandHandler(db);
|
||
var command = new TransferTaskCommand(taskId, fromUserId, toUserId);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>();
|
||
|
||
// 原任务状态不变,未创建新任务
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original!.Status.Should().Be(status);
|
||
var allTasks = await db.WorkflowTasks.ToListAsync();
|
||
allTasks.Should().HaveCount(1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:转办必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可转办他人任务。
|
||
/// 当前缺失授权校验——必须补上。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task TransferTask_ByNonAssignee_ThrowsUnauthorizedException()
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var otherUser = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = fromUserId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new TransferTaskCommandHandler(db);
|
||
// otherUser 冒充转办 fromUserId 的任务
|
||
var command = new TransferTaskCommand(taskId, otherUser, toUserId);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||
|
||
// 原任务状态不变,未创建新任务
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original!.Status.Should().Be(TaskStatus.Pending);
|
||
var allTasks = await db.WorkflowTasks.ToListAsync();
|
||
allTasks.Should().HaveCount(1);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region DelegateTask
|
||
|
||
[Fact]
|
||
public async Task DelegateTask_SetsTaskToDelegated()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
var instanceId = Guid.NewGuid();
|
||
var tokenId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = instanceId,
|
||
TokenId = tokenId,
|
||
AssigneeId = fromUserId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new DelegateTaskCommandHandler(db);
|
||
var command = new DelegateTaskCommand(
|
||
TaskId: taskId,
|
||
FromUserId: fromUserId,
|
||
ToUserId: toUserId
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert - original task should be marked as delegated
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original.Should().NotBeNull();
|
||
original!.Status.Should().Be(TaskStatus.Delegated);
|
||
|
||
// A new delegated task should exist for the target user
|
||
var delegatedTask = await db.WorkflowTasks
|
||
.FirstOrDefaultAsync(t => t.AssigneeId == toUserId && t.DelegatedFromId == fromUserId);
|
||
delegatedTask.Should().NotBeNull();
|
||
delegatedTask!.Status.Should().Be(TaskStatus.Pending);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:非 Pending 状态(已审批/已转办/已委派)的任务不可被委派。
|
||
/// 当前缺失状态校验——必须补上。
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(TaskStatus.Approved)]
|
||
[InlineData(TaskStatus.Rejected)]
|
||
[InlineData(TaskStatus.Transferred)]
|
||
[InlineData(TaskStatus.Delegated)]
|
||
public async Task DelegateTask_NonPendingTask_ThrowsBusinessException(TaskStatus status)
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = fromUserId,
|
||
Status = status
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new DelegateTaskCommandHandler(db);
|
||
var command = new DelegateTaskCommand(taskId, fromUserId, toUserId);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>();
|
||
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original!.Status.Should().Be(status);
|
||
var allTasks = await db.WorkflowTasks.ToListAsync();
|
||
allTasks.Should().HaveCount(1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:委派必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可委派他人任务。
|
||
/// 当前缺失授权校验——必须补上。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task DelegateTask_ByNonAssignee_ThrowsUnauthorizedException()
|
||
{
|
||
await using var db = CreateDbContext();
|
||
var taskId = Guid.NewGuid();
|
||
var fromUserId = Guid.NewGuid();
|
||
var otherUser = Guid.NewGuid();
|
||
var toUserId = Guid.NewGuid();
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = taskId,
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = fromUserId,
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new DelegateTaskCommandHandler(db);
|
||
// otherUser 冒充委派 fromUserId 的任务
|
||
var command = new DelegateTaskCommand(taskId, otherUser, toUserId);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||
|
||
var original = await db.WorkflowTasks.FindAsync(taskId);
|
||
original!.Status.Should().Be(TaskStatus.Pending);
|
||
var allTasks = await db.WorkflowTasks.ToListAsync();
|
||
allTasks.Should().HaveCount(1);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region GetPendingTasks
|
||
|
||
[Fact]
|
||
public async Task GetPendingTasks_ReturnsOnlyUserTasks()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var userId = Guid.NewGuid();
|
||
var otherUserId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Pending
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Pending
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = otherUserId,
|
||
Status = TaskStatus.Pending
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetPendingTasksQueryHandler(db);
|
||
var query = new GetPendingTasksQuery(
|
||
UserId: userId,
|
||
PageIndex: 1,
|
||
PageSize: 10
|
||
);
|
||
|
||
// Act
|
||
var result = await handler.Handle(query, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.Items.Should().HaveCount(2);
|
||
result.Items.Should().OnlyContain(t => t.AssigneeId == userId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetPendingTasks_ExcludesCompletedTasks()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var userId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Pending
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Approved,
|
||
CompletedAt = DateTime.UtcNow
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Rejected,
|
||
CompletedAt = DateTime.UtcNow
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetPendingTasksQueryHandler(db);
|
||
var query = new GetPendingTasksQuery(
|
||
UserId: userId,
|
||
PageIndex: 1,
|
||
PageSize: 10
|
||
);
|
||
|
||
// Act
|
||
var result = await handler.Handle(query, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.Items.Should().HaveCount(1);
|
||
result.Items[0].Status.Should().Be(TaskStatus.Pending);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region GetHistoryTasks
|
||
|
||
[Fact]
|
||
public async Task GetHistoryTasks_ReturnsOnlyProcessedTasks()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var userId = Guid.NewGuid();
|
||
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Approved,
|
||
CompletedAt = DateTime.UtcNow
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Rejected,
|
||
CompletedAt = DateTime.UtcNow
|
||
});
|
||
db.WorkflowTasks.Add(new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = Guid.NewGuid(),
|
||
TokenId = Guid.NewGuid(),
|
||
AssigneeId = userId,
|
||
Status = TaskStatus.Pending
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetHistoryTasksQueryHandler(db);
|
||
var query = new GetHistoryTasksQuery(
|
||
UserId: userId,
|
||
PageIndex: 1,
|
||
PageSize: 10
|
||
);
|
||
|
||
// Act
|
||
var result = await handler.Handle(query, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.Items.Should().HaveCount(2);
|
||
result.Items.Should().OnlyContain(t =>
|
||
t.Status == TaskStatus.Approved || t.Status == TaskStatus.Rejected);
|
||
}
|
||
|
||
#endregion
|
||
}
|