xiangning 2181f7dd49 feat(platform): 完善平台功能与权限管理
- 在菜单系统中新增 RenderType、FormDefinitionId 和 RuntimeViewMode 字段
- 实现自定义表单菜单类型的组件映射和验证逻辑
- 为分片上传相关端点添加 file:upload 权限控制
- 为桶和文件夹操作添加 bucket:write 和 file:write 权限控制
- 为文件共享功能添加 file-share:write 权限控制
- 为文件读取操作添加 file:read 权限控制
- 扩展 AI 模型提供商枚举,支持更多模型供应商
- 在 AI 模型创建和更新时添加枚举值验证
- 重构工作流按钮权限检查逻辑,使用统一的权限码查询方式
- 为 JSON 格式文档添加提取器支持
- 增强表单数据派生服务,支持自定义表单关联解析
- 为表单数据添加按表单定义 ID 的索引优化
- 添加聊天消息搜索和上下文获取的集成测试
- 修正删除菜单端点的 ID 类型从 Guid 改为 long
2026-07-04 19:38:43 +08:00

592 lines
22 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 Workflow.Application.Form.Aggregates;
using Workflow.Application.Form.Derivation;
using Workflow.Application.Form.FormData.Commands;
using Workflow.Application.Form.FormData.Handlers;
using Workflow.Application.Form.FormData.Queries;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.Form;
[Collection("FormTests")]
public class FormDataTests
{
private readonly FormTestFixture _fixture;
public FormDataTests(FormTestFixtureClassFixture fixture)
{
_fixture = fixture;
}
private static SubmitFormDataCommandHandler CreateSubmitHandler(WorkflowDbContext db) =>
new(db, new FormValueDerivationService([], []), new FormAggregateRefreshService(db));
/// <summary>
/// 辅助方法:创建一个已发布的表单定义(含 Formily Schema),用于 FormData 测试。
/// Schema 包含:name (string, required)、age (number)、gender (string, required)、resume (array)。
/// </summary>
private static FormDefinition CreatePublishedFormDefinition(Guid formId)
{
return new FormDefinition
{
Id = formId,
Name = "测试表单",
Code = $"TEST_FORM_{formId:N}",
Version = 1,
Status = FormStatus.Published,
SchemaJson = """
{
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "姓名",
"required": true,
"x-decorator": "FormItem",
"x-component": "Input"
},
"age": {
"type": "number",
"title": "年龄",
"x-decorator": "FormItem",
"x-component": "InputNumber",
"x-component-props": { "min": 0, "max": 150 }
},
"gender": {
"type": "string",
"title": "性别",
"required": true,
"enum": [
{ "label": "男", "value": "male" },
{ "label": "女", "value": "female" }
],
"x-decorator": "FormItem",
"x-component": "Radio.Group"
},
"resume": {
"type": "array",
"title": "简历附件",
"x-decorator": "FormItem",
"x-component": "Upload",
"x-component-props": { "maxSize": 10485760, "accept": ".pdf,.doc,.docx" }
}
}
}
""",
};
}
private const string ComputedExpenseSchema = """
{
"type": "object",
"properties": {
"quantity": { "type": "number", "title": "数量", "x-component": "InputNumber" },
"unitPrice": { "type": "number", "title": "单价", "x-component": "InputNumber" },
"total": {
"type": "number",
"title": "总价",
"x-component": "InputNumber",
"x-field-kind": "computed",
"x-derived": {
"mode": "computed",
"writePolicy": "overwrite",
"expression": {
"operator": "multiply",
"left": { "type": "field", "path": "quantity" },
"right": { "type": "field", "path": "unitPrice" }
}
}
}
}
}
""";
private const string AggregateMainSchema = """
{
"type": "object",
"properties": {
"requestId": { "type": "string", "title": "申请编号", "x-component": "Input" },
"lineTotal": {
"type": "number",
"title": "明细总金额",
"x-component": "InputNumber",
"x-field-kind": "aggregate",
"x-derived": {
"mode": "aggregate",
"source": {
"type": "formData",
"formDefinitionCode": "expense-line",
"relationPath": "requestId",
"relationValuePath": "requestId"
},
"aggregate": { "function": "sum", "fieldPath": "amount" },
"refresh": { "trigger": "afterSubmit", "scope": "sameInstance" }
}
}
}
}
""";
private const string AggregateLineSchema = """
{
"type": "object",
"properties": {
"requestId": { "type": "string", "title": "申请编号", "x-component": "Input" },
"amount": { "type": "number", "title": "金额", "x-component": "InputNumber" }
}
}
""";
// ====================================================================
// SubmitFormData
// ====================================================================
[Fact]
public async Task SubmitFormData_StoresDataJson()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"name":"张三","age":25,"gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_StoresDataJson),
seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId)));
var handler = CreateSubmitHandler(db);
var formDataId = await handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
formDataId.Should().NotBe(Guid.Empty);
var savedData = await db.FormData.FindAsync(formDataId);
savedData.Should().NotBeNull();
savedData!.DataJson.Should().Be(dataJson);
}
[Fact]
public async Task SubmitFormData_WithComputedField_SavesNormalizedData()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_WithComputedField_SavesNormalizedData),
seedAction: ctx =>
{
ctx.FormDefinitions.Add(new FormDefinition
{
Id = formId,
Name = "费用表单",
Code = "expense-form",
Status = FormStatus.Published,
Version = 1,
SchemaJson = ComputedExpenseSchema
});
});
var aggregateService = new RecordingAggregateRefreshService();
var handler = new SubmitFormDataCommandHandler(
db,
new FormValueDerivationService([], []),
aggregateService);
var id = await handler.Handle(
new SubmitFormDataCommand(formId, instanceId, """{"quantity":2,"unitPrice":12,"total":1}"""),
CancellationToken.None);
var saved = await db.FormData.FindAsync(id);
saved!.DataJson.Should().Be("""{"quantity":2,"unitPrice":12,"total":24}""");
aggregateService.RefreshCalls.Should().ContainSingle(call =>
call.FormDefinitionId == formId && call.InstanceId == instanceId);
aggregateService.RefreshAffectedCalls.Should().ContainSingle(call =>
call.FormDefinitionId == formId && call.InstanceId == instanceId);
}
[Fact]
public async Task SubmitFormData_LineForm_RefreshesAggregateOnMainForm()
{
var mainFormId = Guid.NewGuid();
var lineFormId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_LineForm_RefreshesAggregateOnMainForm),
seedAction: ctx =>
{
ctx.FormDefinitions.AddRange(
new FormDefinition
{
Id = mainFormId,
Name = "费用主表",
Code = "expense-main",
Status = FormStatus.Published,
Version = 1,
SchemaJson = AggregateMainSchema,
},
new FormDefinition
{
Id = lineFormId,
Name = "费用明细",
Code = "expense-line",
Status = FormStatus.Published,
Version = 1,
SchemaJson = AggregateLineSchema,
});
ctx.FormData.Add(new FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = mainFormId,
InstanceId = instanceId,
DataJson = """{"requestId":"REQ-001","lineTotal":0}""",
});
});
var aggregateService = new FormAggregateRefreshService(db);
var handler = new SubmitFormDataCommandHandler(
db,
new FormValueDerivationService([], []),
aggregateService);
await handler.Handle(
new SubmitFormDataCommand(lineFormId, instanceId, """{"requestId":"REQ-001","amount":25}"""),
CancellationToken.None);
var main = await db.FormData.SingleAsync(f => f.FormDefinitionId == mainFormId);
main.DataJson.Should().Contain("\"lineTotal\":25");
}
[Fact]
public async Task SubmitFormData_LineForm_RefreshesAggregateInInstanceVariables()
{
var mainFormId = Guid.NewGuid();
var lineFormId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_LineForm_RefreshesAggregateInInstanceVariables),
seedAction: ctx =>
{
ctx.FormDefinitions.AddRange(
new FormDefinition
{
Id = mainFormId,
Name = "费用主表",
Code = "expense-main",
Status = FormStatus.Published,
Version = 1,
SchemaJson = AggregateMainSchema,
},
new FormDefinition
{
Id = lineFormId,
Name = "费用明细",
Code = "expense-line",
Status = FormStatus.Published,
Version = 1,
SchemaJson = AggregateLineSchema,
});
ctx.WorkflowInstances.Add(new WorkflowInstance
{
Id = instanceId,
DefinitionId = Guid.NewGuid(),
Title = "费用流程",
Variables = """{"requestId":"REQ-001","lineTotal":0}""",
});
ctx.FormData.Add(new FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = mainFormId,
InstanceId = instanceId,
DataJson = """{"requestId":"REQ-001","lineTotal":0}""",
});
});
var handler = new SubmitFormDataCommandHandler(
db,
new FormValueDerivationService([], []),
new FormAggregateRefreshService(db));
await handler.Handle(
new SubmitFormDataCommand(lineFormId, instanceId, """{"requestId":"REQ-001","amount":25}"""),
CancellationToken.None);
var instance = await db.WorkflowInstances.FindAsync(instanceId);
instance!.Variables.Should().Contain("\"lineTotal\":25");
}
[Fact]
public async Task SubmitFormData_AssociatesWithFormDefinitionAndInstance()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_AssociatesWithFormDefinitionAndInstance),
seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId)));
var handler = CreateSubmitHandler(db);
var formDataId = await handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: """{"name":"李四","age":30,"gender":"female"}"""),
CancellationToken.None);
var savedData = await db.FormData.FindAsync(formDataId);
savedData.Should().NotBeNull();
savedData!.FormDefinitionId.Should().Be(formId);
savedData.InstanceId.Should().Be(instanceId);
}
[Fact]
public async Task SubmitFormData_RequiredFieldMissing_ThrowsBusinessException()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"age":25,"gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_RequiredFieldMissing_ThrowsBusinessException),
seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId)));
var handler = CreateSubmitHandler(db);
var act = () => handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*必填*姓名*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
[Fact]
public async Task SubmitFormData_InvalidNumberType_ThrowsBusinessException()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"name":"赵六","age":"abc","gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_InvalidNumberType_ThrowsBusinessException),
seedAction: ctx => ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId)));
var handler = CreateSubmitHandler(db);
var act = () => handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*age*类型*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:表单已被软删除时提交数据,必须给出准确错误(表单已被删除),
/// 而非误导性的「表单定义 ... 不存在」(表单实际存在,只是被删除)。
/// </summary>
[Fact]
public async Task SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"name":"张三","age":25,"gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_WithSoftDeletedFormDefinition_ThrowsAccurateError),
seedAction: ctx =>
{
var form = CreatePublishedFormDefinition(formId);
form.IsDeleted = true;
ctx.FormDefinitions.Add(form);
});
var handler = CreateSubmitHandler(db);
var act = () => handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*表单*删除*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:确实不存在的表单 ID,必须给出准确错误(不存在)。
/// 与软删除场景区分,确保错误信息不会混淆。
/// </summary>
[Fact]
public async Task SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsAccurateError()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"name":"张三","age":25,"gender":"male"}""";
await using var db = _fixture.CreateDbContext(
testName: nameof(SubmitFormData_WithTrulyNonExistentFormDefinition_ThrowsAccurateError));
var handler = CreateSubmitHandler(db);
var act = () => handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*不存在*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
/// <summary>
/// 边界:表单状态为 Disabled(已停用)时提交数据,必须严格阻断。
/// 产品决策:Disabled = 严格阻断(新提交一律拒绝)。
/// </summary>
[Fact]
public async Task SubmitFormData_WithDisabledForm_ThrowsBusinessException()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var dataJson = """{"name":"张三","age":25,"gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(SubmitFormData_WithDisabledForm_ThrowsBusinessException),
seedAction: ctx =>
{
var form = CreatePublishedFormDefinition(formId);
form.Status = FormStatus.Disabled;
ctx.FormDefinitions.Add(form);
});
var handler = CreateSubmitHandler(db);
var act = () => handler.Handle(
new SubmitFormDataCommand(FormDefinitionId: formId, InstanceId: instanceId, DataJson: dataJson),
CancellationToken.None);
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*表单*停用*");
var count = await db.FormData.CountAsync();
count.Should().Be(0);
}
// ====================================================================
// GetFormDataByInstance
// ====================================================================
[Fact]
public async Task GetFormDataByInstance_ReturnsFormData()
{
var formId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
var formDataId = Guid.NewGuid();
var dataJson = """{"name":"钱七","age":35,"gender":"male"}""";
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(GetFormDataByInstance_ReturnsFormData),
seedAction: ctx =>
{
ctx.FormDefinitions.Add(CreatePublishedFormDefinition(formId));
ctx.FormData.Add(new FormData
{
Id = formDataId,
FormDefinitionId = formId,
InstanceId = instanceId,
DataJson = dataJson,
});
});
var handler = new GetFormDataByInstanceQueryHandler(db);
var result = await handler.Handle(new GetFormDataByInstanceQuery(InstanceId: instanceId), CancellationToken.None);
result.Should().NotBeNull();
result!.Id.Should().Be(formDataId);
result.DataJson.Should().Be(dataJson);
}
[Fact]
public async Task GetFormDataByInstance_NotFound_ReturnsNull()
{
await using var db = _fixture.CreateDbContext(testName: nameof(GetFormDataByInstance_NotFound_ReturnsNull));
var handler = new GetFormDataByInstanceQueryHandler(db);
var result = await handler.Handle(new GetFormDataByInstanceQuery(InstanceId: Guid.NewGuid()), CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetFormDataList_ReturnsPagedRowsForSelectedFormDefinition()
{
var targetFormId = Guid.NewGuid();
var otherFormId = Guid.NewGuid();
var firstInstanceId = Guid.NewGuid();
var secondInstanceId = Guid.NewGuid();
await using var db = await _fixture.CreateDbContextWithSeedAsync(
testName: nameof(GetFormDataList_ReturnsPagedRowsForSelectedFormDefinition),
seedAction: ctx =>
{
ctx.FormDefinitions.Add(CreatePublishedFormDefinition(targetFormId));
ctx.FormDefinitions.Add(CreatePublishedFormDefinition(otherFormId));
ctx.FormData.AddRange(
new FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = targetFormId,
InstanceId = firstInstanceId,
DataJson = """{"name":"第一条","age":20,"gender":"male"}""",
CreatedAt = DateTime.UtcNow.AddMinutes(-2),
},
new FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = otherFormId,
InstanceId = Guid.NewGuid(),
DataJson = """{"name":"其它表单","age":99,"gender":"female"}""",
CreatedAt = DateTime.UtcNow.AddMinutes(-1),
},
new FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = targetFormId,
InstanceId = secondInstanceId,
DataJson = """{"name":"第二条","age":30,"gender":"female"}""",
CreatedAt = DateTime.UtcNow,
});
});
var handler = new GetFormDataListQueryHandler(db);
var result = await handler.Handle(
new GetFormDataListQuery(targetFormId, PageIndex: 1, PageSize: 10),
CancellationToken.None);
result.Total.Should().Be(2);
result.Items.Select(x => x.FormDefinitionId).Should().OnlyContain(x => x == targetFormId);
result.Items.Select(x => x.InstanceId).Should().Equal(secondInstanceId, firstInstanceId);
}
private sealed class RecordingAggregateRefreshService : IFormAggregateRefreshService
{
public List<(Guid FormDefinitionId, Guid InstanceId)> RefreshCalls { get; } = [];
public List<(Guid FormDefinitionId, Guid InstanceId)> RefreshAffectedCalls { get; } = [];
public Task RefreshAsync(Guid formDefinitionId, Guid instanceId, CancellationToken ct)
{
RefreshCalls.Add((formDefinitionId, instanceId));
return Task.CompletedTask;
}
public Task RefreshAffectedAsync(Guid sourceFormDefinitionId, Guid instanceId, CancellationToken ct)
{
RefreshAffectedCalls.Add((sourceFormDefinitionId, instanceId));
return Task.CompletedTask;
}
}
}