305 lines
12 KiB
C#
305 lines
12 KiB
C#
using BuildFlow.Shared.Domain.Errors;
|
||
using FluentAssertions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Workflow.Application.BuildFlow.Shares.Commands;
|
||
using Workflow.Application.BuildFlow.Shares.DTOs;
|
||
using Workflow.Application.BuildFlow.Shares.Handlers;
|
||
using Workflow.Application.BuildFlow.Shares.Queries;
|
||
using Workflow.Domain.Entities;
|
||
using Workflow.Domain.Enums;
|
||
using Xunit;
|
||
|
||
namespace BuildFlow.Workflow.Tests.BuildFlow.Shares;
|
||
|
||
/// <summary>
|
||
/// [B-补测试] BuildFlow/Shares 子域全 Handler 覆盖。
|
||
/// 覆盖:CreateFormShare / DeleteFormShare / GetFormShareList / GetFormShareByToken / SubmitSharedForm。
|
||
/// 飞书矩阵原标记 workflow/BuildFlow/Shares 零覆盖 → 本次全补。
|
||
/// </summary>
|
||
public class FormShareHandlerTests
|
||
{
|
||
private static async Task<Guid> SeedFormDefinition(WorkflowDbContext db, string schema = "{}")
|
||
{
|
||
var form = new FormDefinition
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Name = "测试表单",
|
||
Code = "test-form",
|
||
SchemaJson = schema,
|
||
};
|
||
db.FormDefinitions.Add(form);
|
||
await db.SaveChangesAsync();
|
||
return form.Id;
|
||
}
|
||
|
||
private static async Task<FormShareDto> SeedShare(
|
||
WorkflowDbContext db, Guid formId, BuildFlowSharePermission permission = BuildFlowSharePermission.View,
|
||
string? password = null, DateTime? expiresAt = null, int? maxViews = null)
|
||
{
|
||
var handler = new CreateFormShareCommandHandler(db);
|
||
return await handler.Handle(
|
||
new CreateFormShareCommand(formId, permission, true, password, expiresAt, maxViews, "备注"),
|
||
CancellationToken.None);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Create_Valid_PersistsAndReturnsToken()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
|
||
var result = await new CreateFormShareCommandHandler(db).Handle(
|
||
new CreateFormShareCommand(formId, BuildFlowSharePermission.Edit, true, null, null, null, "对外收集"),
|
||
CancellationToken.None);
|
||
|
||
result.Token.Should().MatchRegex("^[0-9a-f]{32}$", "token 应为 32 位 hex");
|
||
result.Permission.Should().Be(BuildFlowSharePermission.Edit);
|
||
result.Remark.Should().Be("对外收集");
|
||
(await db.BuildFlowFormShares.CountAsync()).Should().Be(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Create_FormNotFound_ThrowsNotFound()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var handler = new CreateFormShareCommandHandler(db);
|
||
|
||
var act = async () => await handler.Handle(
|
||
new CreateFormShareCommand(Guid.NewGuid(), BuildFlowSharePermission.View, true, null, null, null, null),
|
||
CancellationToken.None);
|
||
|
||
var exception = await act.Should().ThrowAsync<NotFoundException>("表单定义不存在应返回未找到语义");
|
||
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Create_WithPassword_HashesPassword()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
|
||
var result = await new CreateFormShareCommandHandler(db).Handle(
|
||
new CreateFormShareCommand(formId, BuildFlowSharePermission.View, true, "secret123", null, null, null),
|
||
CancellationToken.None);
|
||
|
||
var entity = await db.BuildFlowFormShares.SingleAsync(s => s.Id == result.Id);
|
||
entity.Password.Should().NotBe("secret123", "明文密码不得入库");
|
||
entity.Password.Should().StartWith("$2", "应为 bcrypt 哈希");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Delete_Existing_Removes()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId);
|
||
|
||
await new DeleteFormShareCommandHandler(db).Handle(
|
||
new DeleteFormShareCommand(share.Id), CancellationToken.None);
|
||
|
||
(await db.BuildFlowFormShares.IgnoreQueryFilters().CountAsync(s => s.Id == share.Id))
|
||
.Should().Be(0, "物理删除后不存在");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Delete_NotFound_ThrowsNotFound()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var handler = new DeleteFormShareCommandHandler(db);
|
||
|
||
var act = async () => await handler.Handle(
|
||
new DeleteFormShareCommand(Guid.NewGuid()), CancellationToken.None);
|
||
|
||
var exception = await act.Should().ThrowAsync<NotFoundException>();
|
||
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetList_ReturnsAll()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
await SeedShare(db, formId);
|
||
await SeedShare(db, formId);
|
||
|
||
var result = await new GetFormShareListQueryHandler(db).Handle(
|
||
new GetFormShareListQuery(null, null, 1, 20), CancellationToken.None);
|
||
|
||
result.Total.Should().Be(2);
|
||
result.Items.Should().HaveCount(2);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetList_FiltersByFormDefinitionId()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formA = await SeedFormDefinition(db);
|
||
var formB = await SeedFormDefinition(db);
|
||
await SeedShare(db, formA);
|
||
await SeedShare(db, formB);
|
||
|
||
var result = await new GetFormShareListQueryHandler(db).Handle(
|
||
new GetFormShareListQuery(formA, null, 1, 20), CancellationToken.None);
|
||
|
||
result.Total.Should().Be(1);
|
||
result.Items.Should().ContainSingle(x => x.FormDefinitionId == formA);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_Valid_IncrementsViewCountAndReturnsForm()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db, """{"type":"form"}""");
|
||
var share = await SeedShare(db, formId, BuildFlowSharePermission.View);
|
||
|
||
var result = await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, null), CancellationToken.None);
|
||
|
||
result.FormDefinitionId.Should().Be(formId);
|
||
result.FormName.Should().Be("测试表单");
|
||
result.SchemaJson.Should().Be("""{"type":"form"}""");
|
||
var entity = await db.BuildFlowFormShares.SingleAsync(s => s.Id == share.Id);
|
||
entity.ViewCount.Should().Be(1, "查看次数应自增");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_NotFound_ThrowsNotFound()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
|
||
var act = async () => await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery("nope", null), CancellationToken.None);
|
||
|
||
var exception = await act.Should().ThrowAsync<NotFoundException>();
|
||
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_AssociatedFormNotFound_ThrowsNotFound()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId);
|
||
var form = await db.FormDefinitions.SingleAsync(x => x.Id == formId);
|
||
form.IsDeleted = true;
|
||
await db.SaveChangesAsync();
|
||
|
||
var act = async () => await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, null), CancellationToken.None);
|
||
|
||
var exception = await act.Should().ThrowAsync<NotFoundException>();
|
||
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_Expired_ThrowsBusiness()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||
|
||
var act = async () => await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, null), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>("过期应拒绝");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_MaxViewsReached_ThrowsBusiness()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, maxViews: 1);
|
||
// 先消费一次额度
|
||
await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, null), CancellationToken.None);
|
||
|
||
var act = async () => await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, null), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>("达到查看上限应拒绝");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_PasswordProtected_CorrectPassword_Returns()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, password: "secret123");
|
||
|
||
var result = await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, "secret123"), CancellationToken.None);
|
||
|
||
result.FormDefinitionId.Should().Be(formId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetByToken_PasswordProtected_WrongPassword_ThrowsBusiness()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, password: "secret123");
|
||
|
||
var act = async () => await new GetFormShareByTokenQueryHandler(db).Handle(
|
||
new GetFormShareByTokenQuery(share.Token, "wrong"), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>("密码错误应拒绝");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Submit_TokenNotFound_ThrowsNotFound()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
|
||
var act = async () => await new SubmitSharedFormCommandHandler(db).Handle(
|
||
new SubmitSharedFormCommand("nope", "{}"), CancellationToken.None);
|
||
|
||
var exception = await act.Should().ThrowAsync<NotFoundException>();
|
||
exception.Which.Code.Should().Be(ErrorCode.NotFound);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Submit_Expired_ThrowsBusiness()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, BuildFlowSharePermission.Edit,
|
||
expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||
|
||
var act = async () => await new SubmitSharedFormCommandHandler(db).Handle(
|
||
new SubmitSharedFormCommand(share.Token, "{}"), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>("过期应拒绝");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Submit_ReadOnlyPermission_ThrowsBusiness()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db);
|
||
var share = await SeedShare(db, formId, BuildFlowSharePermission.View);
|
||
|
||
var act = async () => await new SubmitSharedFormCommandHandler(db).Handle(
|
||
new SubmitSharedFormCommand(share.Token, "{}"), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<BusinessException>("只读分享不允许提交");
|
||
}
|
||
|
||
// 有效的 Formily Schema:顶层 object + properties,足以通过 SchemaValidator 与 FormDataValidator。
|
||
private const string ValidFormSchema = "{\"type\":\"object\",\"properties\":{}}";
|
||
|
||
[Fact]
|
||
public async Task Submit_EditPermission_CreatesFormData()
|
||
{
|
||
await using var db = TestDbFactory.Create();
|
||
var formId = await SeedFormDefinition(db, ValidFormSchema);
|
||
var share = await SeedShare(db, formId, BuildFlowSharePermission.Edit);
|
||
|
||
var instanceId = await new SubmitSharedFormCommandHandler(db).Handle(
|
||
new SubmitSharedFormCommand(share.Token, "{}"), CancellationToken.None);
|
||
|
||
instanceId.Should().NotBeEmpty();
|
||
(await db.FormData.CountAsync()).Should().Be(1);
|
||
}
|
||
}
|