- 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
844 lines
28 KiB
C#
844 lines
28 KiB
C#
using FluentAssertions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Workflow.Application.Engine;
|
||
using Workflow.Application.Features.WorkflowInstances.Commands;
|
||
using Workflow.Application.Features.WorkflowInstances.Queries;
|
||
using Workflow.Domain.Entities;
|
||
using Workflow.Domain.Enums;
|
||
using Workflow.Domain.Exceptions;
|
||
using Workflow.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
using TaskStatus = Workflow.Domain.Enums.TaskStatus;
|
||
|
||
namespace Workflow.Tests.Handlers;
|
||
|
||
public class WorkflowInstanceHandlerTests
|
||
{
|
||
private static WorkflowDbContext CreateDbContext()
|
||
{
|
||
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
return new WorkflowDbContext(options);
|
||
}
|
||
|
||
private static async Task<WorkflowDbContext> SeedPublishedDefinitionAsync(
|
||
string code = "leave-approval",
|
||
string name = "Leave Approval",
|
||
Guid? startNodeId = null)
|
||
{
|
||
var db = CreateDbContext();
|
||
var definitionId = Guid.NewGuid();
|
||
var nodeId = startNodeId ?? Guid.NewGuid();
|
||
var approvalNodeId = Guid.NewGuid();
|
||
|
||
var definition = new WorkflowDefinition
|
||
{
|
||
Id = definitionId,
|
||
Name = name,
|
||
Code = code,
|
||
Status = DefinitionStatus.Published,
|
||
Version = 2,
|
||
IsEnabled = true
|
||
};
|
||
db.WorkflowDefinitions.Add(definition);
|
||
|
||
var startNode = new WorkflowNode
|
||
{
|
||
Id = nodeId,
|
||
DefinitionId = definitionId,
|
||
NodeType = NodeType.Start,
|
||
Name = "Start",
|
||
PositionX = 100,
|
||
PositionY = 100
|
||
};
|
||
db.WorkflowNodes.Add(startNode);
|
||
|
||
var approvalNode = new WorkflowNode
|
||
{
|
||
Id = approvalNodeId,
|
||
DefinitionId = definitionId,
|
||
NodeType = NodeType.Approval,
|
||
Name = "Approve",
|
||
Config = """{"assigneeRule": "user:00000000-0000-0000-0000-000000000001"}""",
|
||
PositionX = 300,
|
||
PositionY = 100
|
||
};
|
||
db.WorkflowNodes.Add(approvalNode);
|
||
|
||
db.WorkflowEdges.Add(new WorkflowEdge
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
DefinitionId = definitionId,
|
||
SourceNodeId = nodeId,
|
||
TargetNodeId = approvalNodeId,
|
||
EdgeType = EdgeType.Normal
|
||
});
|
||
|
||
await db.SaveChangesAsync();
|
||
return db;
|
||
}
|
||
|
||
#region StartWorkflowInstance
|
||
|
||
[Fact]
|
||
public async Task StartInstance_CreatesInstanceWithRunningStatus()
|
||
{
|
||
// Arrange
|
||
await using var db = await SeedPublishedDefinitionAsync("running-test");
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "running-test",
|
||
Title: "Test Instance",
|
||
Variables: null,
|
||
FormDataJson: null
|
||
);
|
||
|
||
// Act
|
||
var instanceId = await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
instanceId.Should().NotBe(Guid.Empty);
|
||
|
||
var instance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
instance.Should().NotBeNull();
|
||
instance!.Status.Should().Be(InstanceStatus.Running);
|
||
instance.Title.Should().Be("Test Instance");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartInstance_CreatesInitialTokenAtStartNode()
|
||
{
|
||
// Arrange
|
||
var startNodeId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("token-test", startNodeId: startNodeId);
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "token-test",
|
||
Title: "Token Test",
|
||
Variables: null,
|
||
FormDataJson: null
|
||
);
|
||
|
||
// Act
|
||
var instanceId = await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert — Start→Approval: Start token consumed, Approval token active
|
||
var tokens = await db.WorkflowTokens
|
||
.Where(t => t.InstanceId == instanceId)
|
||
.ToListAsync();
|
||
|
||
tokens.Should().HaveCount(2);
|
||
tokens.Should().Contain(t => t.NodeId == startNodeId && t.Status == TokenStatus.Consumed);
|
||
tokens.Should().Contain(t => t.Status == TokenStatus.Active);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartInstance_WithInvalidDefinitionCode_ThrowsNotFoundException()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "nonexistent-code",
|
||
Title: "Should Fail",
|
||
Variables: null,
|
||
FormDataJson: null
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<NotFoundException>()
|
||
.WithMessage("*nonexistent-code*");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartInstance_WithDisabledDefinition_ThrowsBusinessException()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var definition = new WorkflowDefinition
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Name = "Disabled Workflow",
|
||
Code = "disabled-wf",
|
||
Status = DefinitionStatus.Published,
|
||
Version = 2,
|
||
IsEnabled = false
|
||
};
|
||
db.WorkflowDefinitions.Add(definition);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "disabled-wf",
|
||
Title: "Should Fail",
|
||
Variables: null,
|
||
FormDataJson: null
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*disabled*");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartInstance_WithFormDataJson_MergesFormDataIntoVariables()
|
||
{
|
||
// Arrange
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("form-vars-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "form-vars-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "Expense Form",
|
||
Code = "expense-form",
|
||
Status = FormStatus.Published,
|
||
Version = 1,
|
||
SchemaJson = """{"type":"object","properties":{}}"""
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "form-vars-test",
|
||
Title: "Expense",
|
||
Variables: null,
|
||
FormDataJson: """{"amount":6500,"category":"travel"}"""
|
||
);
|
||
|
||
// Act
|
||
var instanceId = await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var instance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
instance.Should().NotBeNull();
|
||
instance!.Variables.Should().NotBeNull();
|
||
instance.Variables.Should().Contain("\"amount\":6500");
|
||
instance.Variables.Should().Contain("\"category\":\"travel\"");
|
||
|
||
var formData = await db.FormData.SingleAsync(f => f.InstanceId == instanceId);
|
||
formData.FormDefinitionId.Should().Be(formId);
|
||
formData.DataJson.Should().Be("""{"amount":6500,"category":"travel"}""");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:流程定义绑定的启动表单已被软删除时启动实例,必须给出准确错误
|
||
/// (表单已被删除),而非误导性的「表单定义 ... 不存在」。
|
||
/// 且不得创建实例、不得保存运行时记录(Token 等)。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task StartInstance_WithSoftDeletedStartForm_ThrowsAccurateErrorAndPersistsNothing()
|
||
{
|
||
// Arrange
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("deleted-start-form-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "deleted-start-form-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "Deleted Start Form",
|
||
Code = "deleted-start-form",
|
||
Status = FormStatus.Published,
|
||
Version = 1,
|
||
SchemaJson = """{"type":"object","properties":{}}""",
|
||
IsDeleted = true,
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "deleted-start-form-test",
|
||
Title: "X",
|
||
Variables: null,
|
||
FormDataJson: """{"amount":100}"""
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert:准确错误,而非误导性的「不存在」
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*表单*删除*");
|
||
|
||
// 不留任何运行时记录
|
||
(await db.WorkflowInstances.CountAsync()).Should().Be(0);
|
||
(await db.FormData.CountAsync()).Should().Be(0);
|
||
(await db.WorkflowTokens.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:流程定义绑定的启动表单确实不存在(既未存在也未删除),必须给出准确错误(不存在)。
|
||
/// 与软删除场景区分。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task StartInstance_WithTrulyNonExistentStartForm_ThrowsAccurateError()
|
||
{
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("nonexistent-start-form-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "nonexistent-start-form-test");
|
||
definition.FormDefinitionId = formId;
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "nonexistent-start-form-test",
|
||
Title: "X",
|
||
Variables: null,
|
||
FormDataJson: """{"amount":100}"""
|
||
);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*不存在*");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 边界:流程定义绑定的启动表单状态为 Disabled 时启动实例,必须严格阻断。
|
||
/// 产品决策:Disabled = 严格阻断(新启动一律拒绝)。且不得创建任何运行时记录。
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task StartInstance_WithDisabledStartForm_ThrowsBusinessExceptionAndPersistsNothing()
|
||
{
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("disabled-start-form-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "disabled-start-form-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "Disabled Start Form",
|
||
Code = "disabled-start-form",
|
||
Status = FormStatus.Disabled,
|
||
Version = 1,
|
||
SchemaJson = """{"type":"object","properties":{}}""",
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "disabled-start-form-test",
|
||
Title: "X",
|
||
Variables: null,
|
||
FormDataJson: """{"amount":100}"""
|
||
);
|
||
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*表单*停用*");
|
||
|
||
(await db.WorkflowInstances.CountAsync()).Should().Be(0);
|
||
(await db.FormData.CountAsync()).Should().Be(0);
|
||
(await db.WorkflowTokens.CountAsync()).Should().Be(0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartInstance_WithFormDataAndVariables_MergesBothWithExplicitVariablesTakingPrecedence()
|
||
{
|
||
// Arrange
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("merge-vars-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "merge-vars-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "Leave Form",
|
||
Code = "leave-form",
|
||
Status = FormStatus.Published,
|
||
Version = 1,
|
||
SchemaJson = """{"type":"object","properties":{}}"""
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "merge-vars-test",
|
||
Title: "Leave",
|
||
Variables: """{"days":5,"urgent":true}""",
|
||
FormDataJson: """{"days":2,"leaveType":"annual"}"""
|
||
);
|
||
|
||
// Act
|
||
var instanceId = await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var instance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
instance.Should().NotBeNull();
|
||
instance!.Variables.Should().NotBeNull();
|
||
instance.Variables.Should().Contain("\"days\":5");
|
||
instance.Variables.Should().Contain("\"urgent\":true");
|
||
instance.Variables.Should().Contain("\"leaveType\":\"annual\"");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartWorkflowInstance_WithInvalidFormData_ThrowsBusinessExceptionAndDoesNotPersistRuntimeRecords()
|
||
{
|
||
// Arrange
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("invalid-start-form-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "invalid-start-form-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "启动表单",
|
||
Code = "start-form",
|
||
Status = FormStatus.Published,
|
||
Version = 1,
|
||
SchemaJson = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"title": "姓名",
|
||
"required": true,
|
||
"x-component": "Input"
|
||
}
|
||
}
|
||
}
|
||
"""
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
var command = new StartWorkflowInstanceCommand(
|
||
DefinitionCode: "invalid-start-form-test",
|
||
Title: "Invalid Start Form",
|
||
Variables: null,
|
||
FormDataJson: """{"name":""}"""
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*表单数据校验失败*姓名*");
|
||
|
||
db.FormData.Should().BeEmpty();
|
||
db.WorkflowInstances.Should().BeEmpty();
|
||
db.WorkflowTokens.Should().BeEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task StartWorkflowInstance_WithNestedFormData_AcceptsValidAndRejectsMissingNestedRequired()
|
||
{
|
||
// 嵌套表单:FormGrid 容器下的 startDate/endDate 字段 Path 形如 "dateRange.startDate",
|
||
// 提交数据是嵌套对象 {dateRange:{startDate:...}}。FormDataValidator 必须按点分路径取值,
|
||
// 否则带容器的表单无法通过发起流程的校验(回归 bug:扁平 TryGetValue 找不到嵌套值)。
|
||
var formId = Guid.NewGuid();
|
||
await using var db = await SeedPublishedDefinitionAsync("nested-start-form-test");
|
||
var definition = await db.WorkflowDefinitions.FirstAsync(d => d.Code == "nested-start-form-test");
|
||
definition.FormDefinitionId = formId;
|
||
db.FormDefinitions.Add(new FormDefinition
|
||
{
|
||
Id = formId,
|
||
Name = "嵌套启动表单",
|
||
Code = "nested-start-form",
|
||
Status = FormStatus.Published,
|
||
Version = 1,
|
||
SchemaJson = """
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"leaveType": {
|
||
"type": "string",
|
||
"title": "请假类型",
|
||
"required": true,
|
||
"x-component": "Select"
|
||
},
|
||
"dateRange": {
|
||
"type": "void",
|
||
"title": "日期范围",
|
||
"x-component": "FormGrid",
|
||
"properties": {
|
||
"startDate": {
|
||
"type": "string",
|
||
"title": "开始日期",
|
||
"required": true,
|
||
"x-component": "DatePicker"
|
||
},
|
||
"endDate": {
|
||
"type": "string",
|
||
"title": "结束日期",
|
||
"required": true,
|
||
"x-component": "DatePicker"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"""
|
||
});
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new StartWorkflowInstanceCommandHandler(db, new ProcessEngine(db, null!, new()));
|
||
|
||
// 合法嵌套数据:必须成功发起(这是回归 bug 修复的核心断言)
|
||
var instanceId = await handler.Handle(
|
||
new StartWorkflowInstanceCommand(
|
||
"nested-start-form-test",
|
||
"嵌套合法发起",
|
||
null,
|
||
"""{"leaveType":"annual","dateRange":{"startDate":"2026-06-15","endDate":"2026-06-16"}}"""),
|
||
CancellationToken.None);
|
||
|
||
instanceId.Should().NotBeEmpty();
|
||
db.FormData.Should().ContainSingle(f => f.InstanceId == instanceId);
|
||
|
||
// 嵌套必填缺失:必须被拒
|
||
var act = () => handler.Handle(
|
||
new StartWorkflowInstanceCommand(
|
||
"nested-start-form-test",
|
||
"嵌套缺失",
|
||
null,
|
||
"""{"leaveType":"annual","dateRange":{}}"""),
|
||
CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*开始日期*");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region WithdrawWorkflowInstance
|
||
|
||
[Fact]
|
||
public async Task WithdrawInstance_TerminatesInstance_WhenInitiatorAndNoTasksProcessed()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
var initiatorId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "Test",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = initiatorId
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
|
||
var token1 = new WorkflowToken
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = instanceId,
|
||
NodeId = Guid.NewGuid(),
|
||
Status = TokenStatus.Active
|
||
};
|
||
var token2 = new WorkflowToken
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = instanceId,
|
||
NodeId = Guid.NewGuid(),
|
||
Status = TokenStatus.Active
|
||
};
|
||
db.WorkflowTokens.AddRange(token1, token2);
|
||
|
||
// No tasks have been processed (task exists but is pending, not completed)
|
||
var pendingTask = new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = instanceId,
|
||
TokenId = token1.Id,
|
||
AssigneeId = Guid.NewGuid(),
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(pendingTask);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
||
var command = new WithdrawWorkflowInstanceCommand(
|
||
InstanceId: instanceId,
|
||
UserId: initiatorId
|
||
);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updatedInstance = await db.WorkflowInstances.FindAsync(instanceId);
|
||
updatedInstance.Should().NotBeNull();
|
||
updatedInstance!.Status.Should().Be(InstanceStatus.Terminated);
|
||
|
||
var tokens = await db.WorkflowTokens
|
||
.Where(t => t.InstanceId == instanceId)
|
||
.ToListAsync();
|
||
tokens.Should().OnlyContain(t => t.Status == TokenStatus.Terminated);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task WithdrawInstance_Throws_WhenNotInitiator()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
var initiatorId = Guid.NewGuid();
|
||
var otherUserId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "Test",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = initiatorId
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
||
var command = new WithdrawWorkflowInstanceCommand(
|
||
InstanceId: instanceId,
|
||
UserId: otherUserId
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<InvalidStateTransitionException>()
|
||
.WithMessage("*initiator*");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task WithdrawInstance_Throws_WhenTaskAlreadyProcessed()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
var initiatorId = Guid.NewGuid();
|
||
var tokenId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "Test",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = initiatorId
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
|
||
var token = new WorkflowToken
|
||
{
|
||
Id = tokenId,
|
||
InstanceId = instanceId,
|
||
NodeId = Guid.NewGuid(),
|
||
Status = TokenStatus.Active
|
||
};
|
||
db.WorkflowTokens.Add(token);
|
||
|
||
// A task that has already been completed (processed)
|
||
var completedTask = new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = instanceId,
|
||
TokenId = tokenId,
|
||
AssigneeId = Guid.NewGuid(),
|
||
Status = TaskStatus.Approved
|
||
};
|
||
db.WorkflowTasks.Add(completedTask);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new WithdrawWorkflowInstanceCommandHandler(db);
|
||
var command = new WithdrawWorkflowInstanceCommand(
|
||
InstanceId: instanceId,
|
||
UserId: initiatorId
|
||
);
|
||
|
||
// Act
|
||
var act = () => handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
await act.Should().ThrowAsync<InvalidStateTransitionException>()
|
||
.WithMessage("*processed*");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region SuspendWorkflowInstance
|
||
|
||
[Fact]
|
||
public async Task SuspendInstance_ChangesToSuspended()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "To Suspend",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = Guid.NewGuid()
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new SuspendWorkflowInstanceCommandHandler(db);
|
||
var command = new SuspendWorkflowInstanceCommand(InstanceId: instanceId);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updated = await db.WorkflowInstances.FindAsync(instanceId);
|
||
updated.Should().NotBeNull();
|
||
updated!.Status.Should().Be(InstanceStatus.Suspended);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region ResumeWorkflowInstance
|
||
|
||
[Fact]
|
||
public async Task ResumeInstance_ChangesBackToRunning()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "To Resume",
|
||
Status = InstanceStatus.Suspended,
|
||
InitiatorId = Guid.NewGuid()
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new ResumeWorkflowInstanceCommandHandler(db);
|
||
var command = new ResumeWorkflowInstanceCommand(InstanceId: instanceId);
|
||
|
||
// Act
|
||
await handler.Handle(command, CancellationToken.None);
|
||
|
||
// Assert
|
||
var updated = await db.WorkflowInstances.FindAsync(instanceId);
|
||
updated.Should().NotBeNull();
|
||
updated!.Status.Should().Be(InstanceStatus.Running);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region GetWorkflowInstanceList
|
||
|
||
[Fact]
|
||
public async Task GetInstanceList_ReturnsPaged()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var definitionId = Guid.NewGuid();
|
||
for (int i = 1; i <= 12; i++)
|
||
{
|
||
db.WorkflowInstances.Add(new WorkflowInstance
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
DefinitionId = definitionId,
|
||
Title = $"Instance {i}",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = Guid.NewGuid()
|
||
});
|
||
}
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetWorkflowInstanceListQueryHandler(db);
|
||
var query = new GetWorkflowInstanceListQuery(
|
||
PageIndex: 1,
|
||
PageSize: 5,
|
||
Status: null,
|
||
InitiatorId: null
|
||
);
|
||
|
||
// Act
|
||
var result = await handler.Handle(query, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.Items.Should().HaveCount(5);
|
||
result.Total.Should().Be(12);
|
||
result.PageIndex.Should().Be(1);
|
||
result.PageSize.Should().Be(5);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region GetWorkflowInstanceById
|
||
|
||
[Fact]
|
||
public async Task GetInstanceById_IncludesTokensAndTasks()
|
||
{
|
||
// Arrange
|
||
await using var db = CreateDbContext();
|
||
var instanceId = Guid.NewGuid();
|
||
var tokenId = Guid.NewGuid();
|
||
var nodeId = Guid.NewGuid();
|
||
|
||
var instance = new WorkflowInstance
|
||
{
|
||
Id = instanceId,
|
||
DefinitionId = Guid.NewGuid(),
|
||
Title = "Detail Test",
|
||
Status = InstanceStatus.Running,
|
||
InitiatorId = Guid.NewGuid()
|
||
};
|
||
db.WorkflowInstances.Add(instance);
|
||
|
||
var token = new WorkflowToken
|
||
{
|
||
Id = tokenId,
|
||
InstanceId = instanceId,
|
||
NodeId = nodeId,
|
||
Status = TokenStatus.Active
|
||
};
|
||
db.WorkflowTokens.Add(token);
|
||
|
||
var task = new WorkflowTask
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
InstanceId = instanceId,
|
||
TokenId = tokenId,
|
||
AssigneeId = Guid.NewGuid(),
|
||
Status = TaskStatus.Pending
|
||
};
|
||
db.WorkflowTasks.Add(task);
|
||
await db.SaveChangesAsync();
|
||
|
||
var handler = new GetWorkflowInstanceByIdQueryHandler(db);
|
||
var query = new GetWorkflowInstanceByIdQuery(Id: instanceId);
|
||
|
||
// Act
|
||
var result = await handler.Handle(query, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.Id.Should().Be(instanceId);
|
||
result.Tokens.Should().NotBeNull().And.HaveCount(1);
|
||
result.Tasks.Should().NotBeNull().And.HaveCount(1);
|
||
|
||
result.Tokens[0].NodeId.Should().Be(nodeId);
|
||
result.Tasks[0].Status.Should().Be(TaskStatus.Pending);
|
||
}
|
||
|
||
#endregion
|
||
}
|