212 lines
8.5 KiB
C#
212 lines
8.5 KiB
C#
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<WorkflowDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
var db = new WorkflowDbContext(options);
|
||
db.SavingChanges += AuditStub.OnSave;
|
||
return db; }
|
||
|
||
/// <summary>构造 OverdueTaskProcessor:共享 DbContext + 引擎 + 通知捕获器。</summary>
|
||
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<INotificationService>(notifier);
|
||
var provider = services.BuildServiceProvider();
|
||
var engine = new ProcessEngine(db, provider, new ConditionEvaluator());
|
||
var processor = new OverdueTaskProcessor(db, engine, notifier, NullLogger<OverdueTaskProcessor>.Instance);
|
||
return (processor, db, notifier);
|
||
}
|
||
|
||
/// <summary>种子一条已逾期、Running 实例的 Pending 任务 + 完整的下游 End 节点(让 CompleteTaskAsync 能推进)。</summary>
|
||
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();
|
||
}
|
||
}
|