work-flow/tests/Workflow.Tests/Handlers/WorkflowTaskHandlerTests.cs
向宁 9f878286e7 feat: form versioning, notification center, scheduler and webhooks
- 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
2026-06-14 15:03:11 +08:00

1061 lines
35 KiB
C#
Raw Permalink 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 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 标记为已读,状态变为 ReadCompletedAt 被记录。
/// 不涉及 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
}