- 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
239 lines
9.8 KiB
C#
239 lines
9.8 KiB
C#
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();
|
||
}
|
||
}
|