212 lines
8.5 KiB
C#
Raw 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 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();
}
}