work-flow/tests/Workflow.Tests/Scheduler/TimeoutSchedulerTests.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

239 lines
9.8 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 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;
/// <summary>
/// 测试用的 Noop 通知服务:不真正落库通知,仅记录调用情况,便于断言超时调度器是否触发了通知。
/// </summary>
internal sealed class NoopNotificationService : INotificationService
{
public int TimeoutCallCount { get; private set; }
public List<WorkflowTask> TimeoutTasks { get; } = [];
public List<bool> 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;
}
}
/// <summary>
/// OverdueTaskProcessor 核心逻辑测试逾期任务自动处理、Suspended 守卫、autoApproveOnTimeout 解析、
/// DueAt=null 跳过、空转。直接构造处理器实例,不依赖 HostedService/DI scope。
/// </summary>
public class TimeoutSchedulerTests
{
private static WorkflowDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new WorkflowDbContext(options);
}
/// <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();
}
}