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() .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() .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().Should().Be(6500); variables["approvalScore"]!.GetValue().Should().Be(98); } /// /// 边界:节点绑定的表单已被软删除时,审批提交表单必须给出准确错误信息, /// 而不是误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。 /// 此时不可继续提交表单数据。 /// [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() .WithMessage("*表单*删除*"); // 且不得写入任何表单数据 var savedData = await db.FormData.ToListAsync(); savedData.Should().BeEmpty(); } /// /// 边界:节点未绑定任何表单,却提交了表单数据 —— 当前行为是阻断并提示, /// 此测试锁定该不变量,避免误把数据写到未关联的实体。 /// [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() .WithMessage("*未绑定表单*"); var savedData = await db.FormData.ToListAsync(); savedData.Should().BeEmpty(); } /// /// 边界:节点绑定的表单状态为 Disabled 时,审批提交表单必须严格阻断。 /// 产品决策:Disabled = 严格阻断(审批提交一律拒绝)。且不得写入任何表单数据。 /// [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() .WithMessage("*表单*停用*"); var savedData = await db.FormData.ToListAsync(); savedData.Should().BeEmpty(); } #region MarkCcTaskRead /// /// 正常路径:Cc 任务可被其 assignee 标记为已读,状态变为 Read,CompletedAt 被记录。 /// 不涉及 token 路由(Cc 任务为知会性质)。 /// [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(); } /// /// 边界:非 Cc 类型(审批任务)不可被标记已读——标记已读仅适用于知会类任务。 /// [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() .WithMessage("*Cc*"); var task = await db.WorkflowTasks.FindAsync(taskId); task!.Status.Should().Be(TaskStatus.Pending); } /// /// 边界:已读/已完结的 Cc 任务不可重复标记(幂等性/防重复)。 /// [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() .WithMessage("*已读*"); var task = await db.WorkflowTasks.FindAsync(taskId); task!.Status.Should().Be(status); } /// /// 边界:仅 assignee 可标记自己的 Cc 任务已读,非 assignee 被拒绝(防越权)。 /// [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(); var task = await db.WorkflowTasks.FindAsync(taskId); task!.Status.Should().Be(TaskStatus.Pending); } /// /// 边界:不存在的任务 ID 抛 NotFoundException。 /// [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(); } #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(); } /// /// 边界:非 Pending 状态(已审批)的任务不可被转办,否则会重复推进流程。 /// 当前缺失状态校验——必须补上。 /// [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(); // 原任务状态不变,未创建新任务 var original = await db.WorkflowTasks.FindAsync(taskId); original!.Status.Should().Be(status); var allTasks = await db.WorkflowTasks.ToListAsync(); allTasks.Should().HaveCount(1); } /// /// 边界:转办必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可转办他人任务。 /// 当前缺失授权校验——必须补上。 /// [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(); // 原任务状态不变,未创建新任务 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); } /// /// 边界:非 Pending 状态(已审批/已转办/已委派)的任务不可被委派。 /// 当前缺失状态校验——必须补上。 /// [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(); var original = await db.WorkflowTasks.FindAsync(taskId); original!.Status.Should().Be(status); var allTasks = await db.WorkflowTasks.ToListAsync(); allTasks.Should().HaveCount(1); } /// /// 边界:委派必须校验调用者是当前 assignee(与 Approve/Reject 一致),否则任何人可委派他人任务。 /// 当前缺失授权校验——必须补上。 /// [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(); 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 }