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 Workflow.Infrastructure.Persistence; using Xunit; using TaskStatus = Workflow.Domain.Enums.TaskStatus; namespace Workflow.Tests.Scheduler; /// /// 测试用的 Noop 通知服务:不真正落库通知,仅记录调用情况,便于断言超时调度器是否触发了通知。 /// internal sealed class NoopNotificationService : INotificationService { public int TimeoutCallCount { get; private set; } public List TimeoutTasks { get; } = []; public List AutoApprovedFlags { get; } = []; public Task NotifyTaskArrivedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; public Task NotifyTaskApprovedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; public Task NotifyTaskRejectedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; public Task NotifyTaskUrgedAsync(WorkflowTask task, CancellationToken ct = default) => Task.CompletedTask; public Task NotifyTaskTimeoutAsync(WorkflowTask task, bool autoApproved, CancellationToken ct = default) { TimeoutCallCount++; TimeoutTasks.Add(task); AutoApprovedFlags.Add(autoApproved); return Task.CompletedTask; } } /// /// OverdueTaskProcessor 核心逻辑测试:逾期任务自动处理、Suspended 守卫、autoApproveOnTimeout 解析、 /// DueAt=null 跳过、空转。直接构造处理器实例,不依赖 HostedService/DI scope。 /// public class TimeoutSchedulerTests { private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new WorkflowDbContext(options); } /// 构造 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(); } }