using TaskStatus = global::Workflow.Domain.Enums.TaskStatus; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Workflow.Application.Engine; using Workflow.Application.Notifications; using Workflow.Application.Scheduler; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Workflow.Domain.Expressions; using Xunit; namespace BuildFlow.Workflow.Tests.Scheduler; public class TimeoutSchedulerTests { private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var db = new WorkflowDbContext(options); db.SavingChanges += AuditStub.OnSave; return db; } /// 构造 OverdueTaskProcessor:共享 DbContext + 引擎 + 通知捕获器。 private static (OverdueTaskProcessor processor, WorkflowDbContext db, NoopNotificationService notifier) Build(WorkflowDbContext db) { var notifier = new NoopNotificationService(); // ProcessEngine 需要 IServiceProvider 解析 INotificationService;构造一个最小 provider var services = new ServiceCollection(); services.AddSingleton(notifier); var provider = services.BuildServiceProvider(); var engine = new ProcessEngine(db, provider, new ConditionEvaluator()); var processor = new OverdueTaskProcessor(db, engine, notifier, NullLogger.Instance); return (processor, db, notifier); } /// 种子一条已逾期、Running 实例的 Pending 任务 + 完整的下游 End 节点(让 CompleteTaskAsync 能推进)。 private static async Task<(WorkflowInstance instance, WorkflowTask task)> SeedOverdueAsync( WorkflowDbContext db, bool autoApprove, int overdueMinutes = 30, EdgeType edgeType = EdgeType.Approved) { var definitionId = Guid.NewGuid(); var instanceId = Guid.NewGuid(); var approvalNodeId = Guid.NewGuid(); var endNodeId = Guid.NewGuid(); var assigneeId = Guid.NewGuid(); db.WorkflowDefinitions.Add(new WorkflowDefinition { Id = definitionId, Name = "超时测试流程", Code = "timeout-test-" + Guid.NewGuid(), Status = DefinitionStatus.Published, IsEnabled = true }); db.WorkflowNodes.AddRange( new WorkflowNode { Id = approvalNodeId, DefinitionId = definitionId, NodeType = NodeType.Approval, Name = "审批", Config = "{ \"assigneeRule\": \"user:" + assigneeId + "\", \"autoApproveOnTimeout\": " + (autoApprove ? "true" : "false") + " }" }, new WorkflowNode { Id = endNodeId, DefinitionId = definitionId, NodeType = NodeType.End, Name = "结束" }); db.WorkflowEdges.Add(new WorkflowEdge { Id = Guid.NewGuid(), DefinitionId = definitionId, SourceNodeId = approvalNodeId, TargetNodeId = endNodeId, EdgeType = edgeType }); var instance = new WorkflowInstance { Id = instanceId, DefinitionId = definitionId, Title = "测试实例", InitiatorId = Guid.NewGuid(), Status = InstanceStatus.Running }; db.WorkflowInstances.Add(instance); var tokenId = Guid.NewGuid(); db.WorkflowTokens.Add(new WorkflowToken { Id = tokenId, InstanceId = instanceId, NodeId = approvalNodeId, Status = TokenStatus.Active }); var task = new WorkflowTask { Id = Guid.NewGuid(), InstanceId = instanceId, TokenId = tokenId, NodeId = approvalNodeId, Title = "逾期任务", AssigneeId = assigneeId, Type = TaskType.Approval, Status = TaskStatus.Pending, DueAt = DateTime.UtcNow.AddMinutes(-overdueMinutes) // 已逾期 }; db.WorkflowTasks.Add(task); await db.SaveChangesAsync(); return (instance, task); } [Fact] public async Task Execute_AutoApproveTrue_AutoCompletesAsApproved() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); var (instance, task) = await SeedOverdueAsync(db, autoApprove: true); await processor.ExecuteAsync(); var updated = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); updated.Status.Should().Be(TaskStatus.Approved); updated.Comment.Should().Contain("自动通过"); notifier.TimeoutCallCount.Should().Be(1); notifier.AutoApprovedFlags.Should().Contain(true); } [Fact] public async Task Execute_AutoApproveFalse_AutoCompletesAsRejected() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); // 驳回需 Rejected 边 var (instance, task) = await SeedOverdueAsync(db, autoApprove: false, edgeType: EdgeType.Rejected); await processor.ExecuteAsync(); var updated = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); updated.Status.Should().Be(TaskStatus.Rejected); updated.Comment.Should().Contain("自动驳回"); notifier.TimeoutCallCount.Should().Be(1); notifier.AutoApprovedFlags.Should().Contain(false); } [Fact] public async Task Execute_SuspendedInstance_Skipped() { // 关键守卫:CompleteTaskAsync 不检查 instance.Status,调度器必须过滤 Suspended 实例 var db = CreateDbContext(); var (processor, _, notifier) = Build(db); var (instance, task) = await SeedOverdueAsync(db, autoApprove: true); instance.Status = InstanceStatus.Suspended; await db.SaveChangesAsync(); await processor.ExecuteAsync(); var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); unchanged.Status.Should().Be(TaskStatus.Pending, "已挂起实例的任务不应被超时处理"); notifier.TimeoutCallCount.Should().Be(0); } [Fact] public async Task Execute_NoDueAt_Skipped() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); var (_, task) = await SeedOverdueAsync(db, autoApprove: true); task.DueAt = null; await db.SaveChangesAsync(); await processor.ExecuteAsync(); var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); unchanged.Status.Should().Be(TaskStatus.Pending, "无 DueAt 的任务不应被处理"); notifier.TimeoutCallCount.Should().Be(0); } [Fact] public async Task Execute_NotYetOverdue_Skipped() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); var (_, task) = await SeedOverdueAsync(db, autoApprove: true); task.DueAt = DateTime.UtcNow.AddMinutes(30); // 还没到期 await db.SaveChangesAsync(); await processor.ExecuteAsync(); var unchanged = await db.WorkflowTasks.FirstAsync(t => t.Id == task.Id); unchanged.Status.Should().Be(TaskStatus.Pending, "未到期的任务不应被处理"); notifier.TimeoutCallCount.Should().Be(0); } [Fact] public async Task Execute_AlreadyCompleted_Skipped() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); var (_, task) = await SeedOverdueAsync(db, autoApprove: true); task.Status = TaskStatus.Approved; await db.SaveChangesAsync(); await processor.ExecuteAsync(); notifier.TimeoutCallCount.Should().Be(0, "已处理的任务不应再次被超时处理"); } [Fact] public async Task Execute_NoOverdueTasks_Noop() { var db = CreateDbContext(); var (processor, _, notifier) = Build(db); await processor.ExecuteAsync(); // 空库,应正常返回不报错 notifier.TimeoutCallCount.Should().Be(0); } [Fact] public void NodeConfigParser_ParsesTimeoutConfig() { var config = NodeConfigParser.Parse("""{ "timeoutMinutes": 1440, "autoApproveOnTimeout": true }"""); NodeConfigParser.GetInt(config, "timeoutMinutes").Should().Be(1440); NodeConfigParser.GetBool(config, "autoApproveOnTimeout").Should().BeTrue(); } [Fact] public void NodeConfigParser_MissingKeys_ReturnsNull() { var config = NodeConfigParser.Parse("""{ "assigneeRule": "role:manager" }"""); NodeConfigParser.GetInt(config, "timeoutMinutes").Should().BeNull(); NodeConfigParser.GetBool(config, "autoApproveOnTimeout").Should().BeNull(); } }