test: 补充 platform/rag/file 模块单元测试
- platform: 通知与权限模块 handler 测试 - rag: Chat 端到端、知识库 CRUD、嵌入与查询测试 - file: 文件分享下载与文件夹/桶设置测试
This commit is contained in:
parent
f550074fcc
commit
7ecd78550f
@ -1,32 +0,0 @@
|
||||
using MediatR;
|
||||
using Workflow.Application.Features.WorkflowInstances.Commands;
|
||||
|
||||
namespace BuildFlow.Host.Endpoints.Workflow.WorkflowInstance;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 发起流程实例端点。POST /instances,按定义创建实例并产生首个 token。
|
||||
/// </summary>
|
||||
public class StartWorkflowInstanceEndpoint : Endpoint<StartWorkflowInstanceRequest, Guid>
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public StartWorkflowInstanceEndpoint(IMediator mediator) => _mediator = mediator;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workflow-instances");
|
||||
Permissions("workflow-instance:start");
|
||||
Summary(s =>
|
||||
{
|
||||
s.Summary = "Start a new workflow instance";
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(StartWorkflowInstanceRequest req, CancellationToken ct)
|
||||
{
|
||||
var command = new StartWorkflowInstanceCommand(req.DefinitionCode, req.Title, req.Variables, req.FormDataJson);
|
||||
var result = await _mediator.Send(command, ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
293
tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs
Normal file
293
tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs
Normal file
@ -0,0 +1,293 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using File.Application.Common;
|
||||
using File.Application.Features.Buckets.Commands;
|
||||
using File.Application.Features.Buckets.Handlers;
|
||||
using File.Application.Features.Buckets.Queries;
|
||||
using File.Application.Features.Folders.Commands;
|
||||
using File.Application.Features.Folders.DTOs;
|
||||
using File.Application.Features.Folders.Handlers;
|
||||
using File.Application.Features.Folders.Queries;
|
||||
using File.Application.Features.Settings.Commands;
|
||||
using File.Application.Features.Settings.Handlers;
|
||||
using File.Application.Features.Settings.Queries;
|
||||
using File.Application.Features.Shares.Commands;
|
||||
using File.Application.Features.Shares.Handlers;
|
||||
using File.Application.Features.Shares.Queries;
|
||||
using File.Domain.Entities;
|
||||
using File.Infrastructure.Persistence;
|
||||
using File.Infrastructure.Storage;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.File.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-FILE] Folders(非事务部分) + Buckets + Settings + Shares(Create/Delete/Info) 子域补测。
|
||||
/// 注:DeleteFolder/UploadToFolder 涉及事务或补偿,InMemory 支持有限,此处覆盖不依赖事务的核心路径。
|
||||
/// 飞书矩阵: file/Folders+Buckets+Settings+Shares 缺口→补
|
||||
/// </summary>
|
||||
public class FoldersBucketsSettingsTests
|
||||
{
|
||||
private const string OwnerId = "user-001";
|
||||
|
||||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user, Mock<IMediator> mediator) CreateDeps(string name)
|
||||
{
|
||||
var user = new Mock<ICurrentUserContext>();
|
||||
user.SetupGet(u => u.UserId).Returns(OwnerId);
|
||||
user.SetupGet(u => u.IPAddress).Returns("127.0.0.1");
|
||||
var options = new DbContextOptionsBuilder<FileDbContext>()
|
||||
.UseInMemoryDatabase($"file-sub-{name}-{Guid.NewGuid():N}")
|
||||
.AddInterceptors(new AuditInterceptor(user.Object))
|
||||
.Options;
|
||||
return (new FileDbContext(options), new Mock<IS3StorageService>(), user, new Mock<IMediator>());
|
||||
}
|
||||
|
||||
// ===== Folders: Create / Rename / MoveFile (不依赖事务) =====
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFolder_Valid_ReturnsFolderDto()
|
||||
{
|
||||
var (db, _, user, mediator) = CreateDeps("create");
|
||||
var handler = new CreateFolderCommandHandler(db, user.Object, mediator.Object);
|
||||
|
||||
var result = await handler.Handle(new CreateFolderCommand(null, "我的文件夹"), CancellationToken.None);
|
||||
|
||||
result.Name.Should().Be("我的文件夹");
|
||||
result.OwnerId.Should().Be(OwnerId);
|
||||
(await db.Folders.CountAsync()).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFolder_WithParent_LinksCorrectly()
|
||||
{
|
||||
var (db, _, user, mediator) = CreateDeps("child");
|
||||
var createHandler = new CreateFolderCommandHandler(db, user.Object, mediator.Object);
|
||||
var parent = await createHandler.Handle(new CreateFolderCommand(null, "父"), CancellationToken.None);
|
||||
|
||||
var child = await createHandler.Handle(new CreateFolderCommand(parent.Id, "子"), CancellationToken.None);
|
||||
|
||||
child.ParentId.Should().Be(parent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameFolder_Existing_ReturnsNewName()
|
||||
{
|
||||
var (db, _, user, _) = CreateDeps("rename");
|
||||
var createHandler = new CreateFolderCommandHandler(db, user.Object, new Mock<IMediator>().Object);
|
||||
var created = await createHandler.Handle(new CreateFolderCommand(null, "原名"), CancellationToken.None);
|
||||
|
||||
var renameHandler = new RenameFolderCommandHandler(db, user.Object);
|
||||
var result = await renameHandler.Handle(new RenameFolderCommand(created.Id, "新名"), CancellationToken.None);
|
||||
|
||||
result.Name.Should().Be("新名");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveFile_UpdatesFolderId()
|
||||
{
|
||||
var (db, _, user, _) = CreateDeps("move");
|
||||
var createHandler = new CreateFolderCommandHandler(db, user.Object, new Mock<IMediator>().Object);
|
||||
var folder = await createHandler.Handle(new CreateFolderCommand(null, "目标"), CancellationToken.None);
|
||||
var fileId = Guid.NewGuid();
|
||||
db.Files.Add(new FileMeta { Id = fileId, Name = "f.txt", S3Key = "k", S3Bucket = "b", ContentType = "text", OwnerId = OwnerId });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new MoveFileCommandHandler(db, user.Object);
|
||||
await handler.Handle(new MoveFileCommand(fileId, folder.Id), CancellationToken.None);
|
||||
|
||||
(await db.Files.FindAsync(fileId))!.FolderId.Should().Be(folder.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRootFiles_ReturnsCurrentUserRootFiles()
|
||||
{
|
||||
var (db, s3, user, _) = CreateDeps("root");
|
||||
db.Files.Add(new FileMeta { Name = "root1.txt", S3Key = "k1", S3Bucket = "b", ContentType = "text", OwnerId = OwnerId, Size = 100 });
|
||||
db.Files.Add(new FileMeta { Name = "other.txt", S3Key = "k2", S3Bucket = "b", ContentType = "text", OwnerId = "other-user", Size = 50 });
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetRootFilesQueryHandler(db, user.Object, s3.Object);
|
||||
|
||||
var result = await handler.Handle(new GetRootFilesQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Name.Should().Be("root1.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFolderTree_ReturnsUserFolders()
|
||||
{
|
||||
var (db, s3, user, _) = CreateDeps("tree");
|
||||
var createHandler = new CreateFolderCommandHandler(db, user.Object, new Mock<IMediator>().Object);
|
||||
await createHandler.Handle(new CreateFolderCommand(null, "F1"), CancellationToken.None);
|
||||
await createHandler.Handle(new CreateFolderCommand(null, "F2"), CancellationToken.None);
|
||||
var handler = new GetFolderTreeQueryHandler(db, user.Object, s3.Object);
|
||||
|
||||
var result = await handler.Handle(new GetFolderTreeQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ===== Buckets(纯 S3 代理,无 DB) =====
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBucket_CallsS3Create()
|
||||
{
|
||||
var s3 = new Mock<IS3StorageService>();
|
||||
var handler = new CreateBucketCommandHandler(s3.Object);
|
||||
|
||||
var result = await handler.Handle(new CreateBucketCommand("my-bucket"), CancellationToken.None);
|
||||
|
||||
result.Should().Be("bucket created");
|
||||
s3.Verify(x => x.CreateBucketAsync("my-bucket", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBucket_CallsS3Delete()
|
||||
{
|
||||
var s3 = new Mock<IS3StorageService>();
|
||||
var handler = new DeleteBucketCommandHandler(s3.Object);
|
||||
|
||||
var result = await handler.Handle(new DeleteBucketCommand("my-bucket"), CancellationToken.None);
|
||||
|
||||
result.Should().Be("bucket deleted");
|
||||
s3.Verify(x => x.DeleteBucketAsync("my-bucket", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBuckets_ReturnsS3List()
|
||||
{
|
||||
var s3 = new Mock<IS3StorageService>();
|
||||
s3.Setup(x => x.ListBucketsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "bucket-a", "bucket-b" });
|
||||
var handler = new ListBucketsQueryHandler(s3.Object);
|
||||
|
||||
var result = await handler.Handle(new ListBucketsQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2).And.Contain("bucket-a");
|
||||
}
|
||||
|
||||
// ===== Settings =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetFileSetting_Empty_ReturnsDefault()
|
||||
{
|
||||
var (db, _, _, _) = CreateDeps("empty-settings");
|
||||
var handler = new GetFileSettingQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetFileSettingQuery(), CancellationToken.None);
|
||||
|
||||
result.DefaultBucket.Should().Be("build-flow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDefaultBucket_UpsertsSingleRow()
|
||||
{
|
||||
var (db, _, _, _) = CreateDeps("update-settings");
|
||||
var handler = new UpdateDefaultBucketCommandHandler(db);
|
||||
|
||||
var result = await handler.Handle(new UpdateDefaultBucketCommand("custom-bucket"), CancellationToken.None);
|
||||
|
||||
result.DefaultBucket.Should().Be("custom-bucket");
|
||||
(await db.Settings.CountAsync()).Should().Be(1);
|
||||
(await db.Settings.FirstAsync()).Id.Should().Be(1, "单行配置 Id 固定 1");
|
||||
}
|
||||
|
||||
// ===== Shares: Create / Delete / GetInfo =====
|
||||
|
||||
[Fact]
|
||||
public async Task CreateShare_Valid_GeneratesTokenAndBcryptHash()
|
||||
{
|
||||
var (db, _, user, mediator) = CreateDeps("create-share");
|
||||
var fileId = Guid.NewGuid();
|
||||
db.Files.Add(new FileMeta { Id = fileId, Name = "doc.pdf", S3Key = "k", S3Bucket = "b", ContentType = "application/pdf", OwnerId = OwnerId });
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new CreateShareCommandHandler(db, user.Object, mediator.Object);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateShareCommand("file", fileId, "secret123", null, null), CancellationToken.None);
|
||||
|
||||
result.Token.Should().HaveLength(32, "16字节随机→32位hex");
|
||||
result.Password.Should().BeEmpty("响应不回传密码明文");
|
||||
// 验证 DB 里存的是 BCrypt 哈希(而非明文)
|
||||
var shareInDb = await db.ShareLinks.FirstAsync();
|
||||
shareInDb.Password.Should().NotBe("secret123", "DB 不得存明文");
|
||||
BCrypt.Net.BCrypt.Verify("secret123", shareInDb.Password).Should().BeTrue("DB 存 BCrypt 哈希可被校验");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateShare_InvalidResourceType_ThrowsBusiness()
|
||||
{
|
||||
var (db, _, user, mediator) = CreateDeps("bad-type");
|
||||
var handler = new CreateShareCommandHandler(db, user.Object, mediator.Object);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateShareCommand("invalid", Guid.NewGuid(), null, null, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>("非法 ResourceType 应拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShare_Own_RemovesHard()
|
||||
{
|
||||
var (db, _, user, _) = CreateDeps("del-share");
|
||||
var share = new ShareLink { ResourceType = "file", ResourceId = Guid.NewGuid(), Token = "tok12345678901234567890123456789" };
|
||||
db.ShareLinks.Add(share);
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new DeleteShareCommandHandler(db, user.Object);
|
||||
|
||||
await handler.Handle(new DeleteShareCommand(share.Id), CancellationToken.None);
|
||||
|
||||
(await db.ShareLinks.AnyAsync(s => s.Id == share.Id)).Should().BeFalse("硬删除");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteShare_NotOwner_ThrowsNotFound()
|
||||
{
|
||||
var (db, _, user, _) = CreateDeps("del-other");
|
||||
var share = new ShareLink { ResourceType = "file", ResourceId = Guid.NewGuid(), Token = "tok12345678901234567890123456789" };
|
||||
db.ShareLinks.Add(share);
|
||||
await db.SaveChangesAsync();
|
||||
// user-001 是 owner(AuditInterceptor 填),但我们要模拟"他人"删
|
||||
var otherUser = new Mock<ICurrentUserContext>();
|
||||
otherUser.SetupGet(u => u.UserId).Returns("other-user");
|
||||
var handler = new DeleteShareCommandHandler(db, otherUser.Object);
|
||||
|
||||
var act = async () => await handler.Handle(new DeleteShareCommand(share.Id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>("非 owner 删应 NotFound(防枚举)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShareInfo_ValidToken_ReturnsInfo()
|
||||
{
|
||||
var (db, _, _, _) = CreateDeps("info");
|
||||
var fileId = Guid.NewGuid();
|
||||
db.Files.Add(new FileMeta { Id = fileId, Name = "shared.pdf", S3Key = "k", S3Bucket = "b", ContentType = "application/pdf", OwnerId = OwnerId, Size = 2048 });
|
||||
db.ShareLinks.Add(new ShareLink { ResourceType = "file", ResourceId = fileId, Token = "infotoken1234567890123456789012" });
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetShareInfoQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetShareInfoQuery("infotoken1234567890123456789012"), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.FileName.Should().Be("shared.pdf");
|
||||
result.FileSize.Should().Be(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetShareInfo_UnknownToken_ReturnsNull()
|
||||
{
|
||||
var (db, _, _, _) = CreateDeps("info-404");
|
||||
var handler = new GetShareInfoQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetShareInfoQuery("nonexistent12345678901234567890"), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
152
tests/BuildFlow.File.Tests/ShareDownloadTests.cs
Normal file
152
tests/BuildFlow.File.Tests/ShareDownloadTests.cs
Normal file
@ -0,0 +1,152 @@
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using File.Application.Features.Shares.Handlers;
|
||||
using File.Application.Features.Shares.Queries;
|
||||
using File.Domain.Entities;
|
||||
using File.Domain.Exceptions;
|
||||
using File.Infrastructure.Persistence;
|
||||
using File.Infrastructure.Storage;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.File.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-FILE-02] 分享下载端到端测试。
|
||||
/// 覆盖:正常下载/过期/次数耗尽/密码校验。
|
||||
/// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/GeOwweCmfi1yMLk62iIcOKLInOE
|
||||
/// </summary>
|
||||
public class ShareDownloadTests
|
||||
{
|
||||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<IMediator> mediator) CreateDeps(string dbName)
|
||||
{
|
||||
var user = new Mock<ICurrentUserContext>();
|
||||
user.SetupGet(u => u.UserId).Returns("user-001");
|
||||
user.SetupGet(u => u.IPAddress).Returns("127.0.0.1");
|
||||
|
||||
var options = new DbContextOptionsBuilder<FileDbContext>()
|
||||
.UseInMemoryDatabase($"share-{dbName}-{Guid.NewGuid():N}")
|
||||
.AddInterceptors(new AuditInterceptor(user.Object))
|
||||
.Options;
|
||||
var db = new FileDbContext(options);
|
||||
|
||||
var s3 = new Mock<IS3StorageService>();
|
||||
s3.Setup(x => x.GeneratePresignedUrlAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("https://presigned.example.com/file");
|
||||
|
||||
return (db, s3, new Mock<IMediator>());
|
||||
}
|
||||
|
||||
private static async Task<(FileDbContext db, DownloadShareQueryHandler handler)> SetupWithShareAsync(
|
||||
string name, ShareLink share)
|
||||
{
|
||||
var (db, s3, mediator) = CreateDeps(name);
|
||||
share.ResourceType = "file"; // 必填(folder/file)
|
||||
db.ShareLinks.Add(share);
|
||||
db.Files.Add(new FileMeta { Id = share.ResourceId, Name = "doc.pdf", S3Key = "k", S3Bucket = "b", ContentType = "application/pdf", OwnerId = "user-001" });
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new DownloadShareQueryHandler(db, s3.Object, mediator.Object, NullLogger<DownloadShareQueryHandler>.Instance);
|
||||
return (db, handler);
|
||||
}
|
||||
|
||||
private static string UniqueToken(string seed) => (seed + "padding1234567890123456789012")[..32];
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ValidShare_ReturnsPresignedUrl()
|
||||
{
|
||||
var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("valid") };
|
||||
var (_, handler) = await SetupWithShareAsync("valid", share);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new DownloadShareQuery(share.Token, null), CancellationToken.None);
|
||||
|
||||
result.PresignedUrl.Should().StartWith("https://presigned");
|
||||
result.FileName.Should().Be("doc.pdf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_Expired_ThrowsShareExpired()
|
||||
{
|
||||
var share = new ShareLink
|
||||
{
|
||||
ResourceId = Guid.NewGuid(),
|
||||
Token = UniqueToken("expired"),
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(-10)
|
||||
};
|
||||
var (_, handler) = await SetupWithShareAsync("expired", share);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new DownloadShareQuery(share.Token, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ShareExpiredException>("过期分享应被拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_Exhausted_ThrowsShareExhausted()
|
||||
{
|
||||
var share = new ShareLink
|
||||
{
|
||||
ResourceId = Guid.NewGuid(),
|
||||
Token = UniqueToken("exhausted"),
|
||||
MaxDownloads = 3,
|
||||
DownloadCount = 3
|
||||
};
|
||||
var (_, handler) = await SetupWithShareAsync("exhausted", share);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new DownloadShareQuery(share.Token, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ShareExhaustedException>("次数耗尽应被拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ProtectedNoPassword_ThrowsSharePassword()
|
||||
{
|
||||
var share = new ShareLink
|
||||
{
|
||||
ResourceId = Guid.NewGuid(),
|
||||
Token = UniqueToken("pwd-missing"),
|
||||
Password = BCrypt.Net.BCrypt.HashPassword("secret123")
|
||||
};
|
||||
var (_, handler) = await SetupWithShareAsync("pwd-missing", share);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new DownloadShareQuery(share.Token, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<SharePasswordException>("无密码访问受保护分享应被拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ProtectedWrongPassword_ThrowsSharePassword()
|
||||
{
|
||||
var share = new ShareLink
|
||||
{
|
||||
ResourceId = Guid.NewGuid(),
|
||||
Token = UniqueToken("pwd-wrong"),
|
||||
Password = BCrypt.Net.BCrypt.HashPassword("secret123")
|
||||
};
|
||||
var (_, handler) = await SetupWithShareAsync("pwd-wrong", share);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new DownloadShareQuery(share.Token, "wrongpassword"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<SharePasswordException>("密码错误应被拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_NonExistentToken_ThrowsNotFound()
|
||||
{
|
||||
var (db, s3, mediator) = CreateDeps("not-found");
|
||||
var handler = new DownloadShareQueryHandler(db, s3.Object, mediator.Object, NullLogger<DownloadShareQueryHandler>.Instance);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new DownloadShareQuery(UniqueToken("nope"), null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>("不存在的 token 应返回 NotFound");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
using BuildFlow.Platform.Application.Notifications.Commands;
|
||||
using BuildFlow.Platform.Application.Notifications.DTOs;
|
||||
using BuildFlow.Platform.Application.Notifications.Handlers;
|
||||
using BuildFlow.Platform.Application.Notifications.Queries;
|
||||
using BuildFlow.Platform.Domain.Entities;
|
||||
using BuildFlow.Platform.Infrastructure.Persistence;
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using INotificationDispatcher = BuildFlow.Platform.Application.Notifications.INotificationDispatcher;
|
||||
|
||||
namespace BuildFlow.Platform.Tests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-PLATFORM-02] 通知系统全 Handler 覆盖测试。
|
||||
/// 覆盖:Publish(Fanout) / GetNotifications(分页) / GetUnreadCount / MarkRead / MarkAllRead。
|
||||
/// 飞书矩阵: platform/Notifications 子域(5 Handler 零覆盖→全补)
|
||||
/// </summary>
|
||||
public class NotificationHandlerTests
|
||||
{
|
||||
private static PlatformDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "")
|
||||
=> PlatformTestDb.CreateDbContext(name);
|
||||
|
||||
private static Notification NewNotification(Guid recipient, bool read = false, string type = "system")
|
||||
=> PlatformTestDb.WithAudit(new Notification
|
||||
{
|
||||
RecipientUserId = recipient,
|
||||
Type = type,
|
||||
Title = "测试通知",
|
||||
Content = "内容",
|
||||
Source = "system",
|
||||
IsRead = read,
|
||||
ReadAt = read ? DateTime.UtcNow : null
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_WithRecipientUserIds_FanoutAndDispatch()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var dispatcher = Substitute.For<INotificationDispatcher>();
|
||||
var u1 = Guid.NewGuid();
|
||||
var u2 = Guid.NewGuid();
|
||||
var handler = new PublishNotificationCommandHandler(db, dispatcher);
|
||||
|
||||
var count = await handler.Handle(new PublishNotificationCommand(
|
||||
"task-arrived", "标题", "内容", "workflow", null, "workflow-instance",
|
||||
new[] { u1, u2 }, null), CancellationToken.None);
|
||||
|
||||
count.Should().Be(2);
|
||||
(await db.Notifications.IgnoreQueryFilters().ToListAsync())
|
||||
.Should().HaveCount(2);
|
||||
await dispatcher.Received(1).DispatchAsync(
|
||||
Arg.Is<IReadOnlyList<Notification>>(l => l.Count == 2),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_NoRecipients_ReturnsZeroAndNoDispatch()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var dispatcher = Substitute.For<INotificationDispatcher>();
|
||||
var handler = new PublishNotificationCommandHandler(db, dispatcher);
|
||||
|
||||
var count = await handler.Handle(new PublishNotificationCommand(
|
||||
"system", "t", "c", "system", null, null, null, null), CancellationToken.None);
|
||||
|
||||
count.Should().Be(0);
|
||||
await dispatcher.DidNotReceiveWithAnyArgs().DispatchAsync(default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotifications_Paginated_ReturnsUserOnly()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var me = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
db.Notifications.AddRange(NewNotification(me), NewNotification(me), NewNotification(other));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetNotificationsQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetNotificationsQuery(me, false, 1, 10), CancellationToken.None);
|
||||
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Should().OnlyContain(n => n.RecipientUserId == me);
|
||||
result.Total.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotifications_UnreadOnly_FiltersRead()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var me = Guid.NewGuid();
|
||||
db.Notifications.AddRange(NewNotification(me, read: true), NewNotification(me, read: false));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetNotificationsQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetNotificationsQuery(me, true, 1, 10), CancellationToken.None);
|
||||
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].IsRead.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnreadCount_ReturnsOnlyUnread()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var me = Guid.NewGuid();
|
||||
db.Notifications.AddRange(
|
||||
NewNotification(me, read: false),
|
||||
NewNotification(me, read: false),
|
||||
NewNotification(me, read: true));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetUnreadNotificationCountQueryHandler(db);
|
||||
|
||||
var count = await handler.Handle(new GetUnreadNotificationCountQuery(me), CancellationToken.None);
|
||||
|
||||
count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkRead_ExistingUnread_MarksAndReturnsTrue()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var me = Guid.NewGuid();
|
||||
var n = NewNotification(me, read: false);
|
||||
db.Notifications.Add(n);
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new MarkNotificationReadCommandHandler(db);
|
||||
|
||||
var ok = await handler.Handle(new MarkNotificationReadCommand(n.Id, me), CancellationToken.None);
|
||||
|
||||
ok.Should().BeTrue();
|
||||
(await db.Notifications.FindAsync(n.Id))!.IsRead.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkRead_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new MarkNotificationReadCommandHandler(db);
|
||||
|
||||
var ok = await handler.Handle(
|
||||
new MarkNotificationReadCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
ok.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkAllRead_MarksAllForUser()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var me = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
db.Notifications.AddRange(
|
||||
NewNotification(me, read: false),
|
||||
NewNotification(me, read: false),
|
||||
NewNotification(other, read: false));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new MarkAllNotificationsReadCommandHandler(db);
|
||||
|
||||
var affected = await handler.Handle(new MarkAllNotificationsReadCommand(me), CancellationToken.None);
|
||||
|
||||
affected.Should().Be(2);
|
||||
(await db.Notifications.CountAsync(n => n.RecipientUserId == me && !n.IsRead)).Should().Be(0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
using BuildFlow.Platform.Application.Permissions.Commands;
|
||||
using BuildFlow.Platform.Application.Permissions.DTOs;
|
||||
using BuildFlow.Platform.Application.Permissions.Handlers;
|
||||
using BuildFlow.Platform.Application.Permissions.Queries;
|
||||
using BuildFlow.Platform.Domain.Entities;
|
||||
using BuildFlow.Platform.Infrastructure.Persistence;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Platform.Tests.Permissions;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-PLATFORM] 权限管理全 Handler 覆盖测试。
|
||||
/// 覆盖:Create / Update / Delete / GetList / GetById / GetTree。
|
||||
/// 飞书矩阵: platform/Permissions 子域(6 Handler 零覆盖→全补)
|
||||
/// </summary>
|
||||
public class PermissionHandlerTests
|
||||
{
|
||||
private static PlatformDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "")
|
||||
=> PlatformTestDb.CreateDbContext(name);
|
||||
|
||||
private static Permission NewPermission(string code, string? group = "TestGroup")
|
||||
=> PlatformTestDb.WithAudit(new Permission
|
||||
{
|
||||
Name = $"权限_{code}",
|
||||
Code = code,
|
||||
Description = "desc",
|
||||
Group = group
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Valid_ReturnsDto()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new CreatePermissionCommandHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreatePermissionCommand("创建用户", "user:create", "描述", "UserManagement"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Code.Should().Be("user:create");
|
||||
result.Name.Should().Be("创建用户");
|
||||
(await db.Permissions.CountAsync()).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateCode_ThrowsBusiness()
|
||||
{
|
||||
var db = CreateDb();
|
||||
db.Permissions.Add(NewPermission("user:create"));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new CreatePermissionCommandHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new CreatePermissionCommand("重复", "user:create", null, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>("Code 唯一,重复应拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_Existing_OverwritesNullableFields()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var p = NewPermission("user:read");
|
||||
db.Permissions.Add(p);
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new UpdatePermissionCommandHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new UpdatePermissionCommand(p.Id, "新名称", "新描述", "NewGroup"), CancellationToken.None);
|
||||
|
||||
result.Name.Should().Be("新名称");
|
||||
result.Description.Should().Be("新描述");
|
||||
result.Group.Should().Be("NewGroup");
|
||||
result.Code.Should().Be("user:read", "Code 不可改");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistent_ThrowsNotFound()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new UpdatePermissionCommandHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePermissionCommand(Guid.NewGuid(), "x", null, null), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Existing_RemovesAndUnlinksRoles()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var p = NewPermission("user:delete");
|
||||
var role = PlatformTestDb.WithAudit(new Role { Name = "Admin" });
|
||||
var link = new RolePermission { Role = role, Permission = p };
|
||||
db.Permissions.Add(p);
|
||||
db.Roles.Add(role);
|
||||
db.RolePermissions.Add(link);
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new DeletePermissionCommandHandler(db);
|
||||
|
||||
await handler.Handle(new DeletePermissionCommand(p.Id), CancellationToken.None);
|
||||
|
||||
(await db.Permissions.AnyAsync(x => x.Id == p.Id)).Should().BeFalse("软删后不可见");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ThrowsNotFound()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new DeletePermissionCommandHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(new DeletePermissionCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_ReturnsAll()
|
||||
{
|
||||
var db = CreateDb();
|
||||
db.Permissions.AddRange(NewPermission("p1"), NewPermission("p2"));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetPermissionListQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetPermissionListQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_Existing_ReturnsDto()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var p = NewPermission("user:read");
|
||||
db.Permissions.Add(p);
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetPermissionByIdQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetPermissionByIdQuery(p.Id), CancellationToken.None);
|
||||
|
||||
result.Code.Should().Be("user:read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NonExistent_ThrowsNotFound()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new GetPermissionByIdQueryHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(new GetPermissionByIdQuery(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_GroupsByGroupField()
|
||||
{
|
||||
var db = CreateDb();
|
||||
db.Permissions.AddRange(
|
||||
NewPermission("a:1", "GroupA"),
|
||||
NewPermission("a:2", "GroupA"),
|
||||
NewPermission("b:1", "GroupB"));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetPermissionTreeQueryHandler(db);
|
||||
|
||||
var tree = await handler.Handle(new GetPermissionTreeQuery(), CancellationToken.None);
|
||||
|
||||
tree.Should().HaveCount(2);
|
||||
tree.First(g => g.Group == "GroupA").Permissions.Should().HaveCount(2);
|
||||
tree.First(g => g.Group == "GroupB").Permissions.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_NullGroup_GoesToUngrouped()
|
||||
{
|
||||
var db = CreateDb();
|
||||
db.Permissions.Add(NewPermission("no:group", null));
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetPermissionTreeQueryHandler(db);
|
||||
|
||||
var tree = await handler.Handle(new GetPermissionTreeQuery(), CancellationToken.None);
|
||||
|
||||
tree.Should().ContainSingle();
|
||||
tree[0].Group.Should().Be("未分组");
|
||||
}
|
||||
}
|
||||
142
tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs
Normal file
142
tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using BuildFlow.Application.Chat.Commands;
|
||||
using BuildFlow.Application.Chat.Handlers;
|
||||
using BuildFlow.Domain.Entities;
|
||||
using BuildFlow.Domain.Enums;
|
||||
using BuildFlow.Infrastructure.Persistence;
|
||||
using BuildFlow.Rag.Tests.Stub;
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Rag.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-RAG-02] 多轮对话端到端测试。
|
||||
/// 不依赖 pgvector。验证:CreateConversation → SendMessage(两轮)→ 历史拼装 → cache 一致性。
|
||||
/// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/CBrcwbDH6ialzFkF1cgcnvKenHM
|
||||
/// </summary>
|
||||
public class ChatEndToEndTests
|
||||
{
|
||||
private static RagDbContext CreateDb(string name)
|
||||
{
|
||||
// 挂生产 AuditInterceptor,自动填充 CreatedBy/UpdatedBy/OperatorIP 等审计字段
|
||||
// (InMemory 默认无拦截器,导致必填审计字段为空抛 DbUpdateException)
|
||||
var userContext = new TestCurrentUserContext();
|
||||
var options = new DbContextOptionsBuilder<RagDbContext>()
|
||||
.UseInMemoryDatabase($"rag-chat-{name}-{Guid.NewGuid():N}")
|
||||
.AddInterceptors(new AuditInterceptor(userContext))
|
||||
.Options;
|
||||
return new RagDbContext(options);
|
||||
}
|
||||
|
||||
private static void SeedConversation(RagDbContext db, Guid conversationId, Guid userId)
|
||||
{
|
||||
db.Conversations.Add(new Conversation
|
||||
{
|
||||
Id = conversationId,
|
||||
Title = "测试会话",
|
||||
UserId = userId
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversation_ReturnsConversationDto()
|
||||
{
|
||||
// Given
|
||||
await using var db = CreateDb("create");
|
||||
var userContext = new TestCurrentUserContext();
|
||||
var handler = new CreateConversationCommandHandler(db, userContext);
|
||||
|
||||
// When
|
||||
var result = await handler.Handle(
|
||||
new CreateConversationCommand("我的对话", null),
|
||||
CancellationToken.None);
|
||||
|
||||
// Then
|
||||
result.Title.Should().Be("我的对话");
|
||||
result.UserId.Should().Be(Guid.Parse(userContext.UserId));
|
||||
result.Id.Should().NotBeEmpty();
|
||||
db.Conversations.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_TwoRounds_HistoryAssembledAndCacheConsistent()
|
||||
{
|
||||
// Given: 已创建会话 + 两轮对话
|
||||
await using var db = CreateDb("multi-turn");
|
||||
var userId = Guid.Parse(new TestCurrentUserContext().UserId);
|
||||
var conversationId = Guid.NewGuid();
|
||||
SeedConversation(db, conversationId, userId);
|
||||
|
||||
var cache = new StubChatMessageCache();
|
||||
var chatAgent = new StubAIChatAgent { ReplyText = "你好!我是 AI 助手。" };
|
||||
var handler = new SendMessageCommandHandler(db, chatAgent, cache);
|
||||
|
||||
// When: 第一轮
|
||||
var round1 = await handler.Handle(
|
||||
new SendMessageCommand(conversationId, "你好"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Then: 第一轮返回 messageId + 内容
|
||||
round1.MessageId.Should().NotBeEmpty();
|
||||
round1.Content.Should().Be("你好!我是 AI 助手。");
|
||||
|
||||
// DB: 1 user + 1 assistant = 2 条消息
|
||||
db.ChatMessages.Should().HaveCount(2);
|
||||
db.ChatMessages.Count(m => m.Role == ChatRole.User).Should().Be(1);
|
||||
db.ChatMessages.Count(m => m.Role == ChatRole.Assistant).Should().Be(1);
|
||||
|
||||
// Cache: 同步 2 条
|
||||
cache.Count(conversationId).Should().Be(2, "cache 与 DB 一致");
|
||||
|
||||
// When: 第二轮(验证历史拼装 - Stub chatAgent 不依赖历史但 Handler 应查历史)
|
||||
var round2 = await handler.Handle(
|
||||
new SendMessageCommand(conversationId, "我刚才说了什么?"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Then: 第二轮同样返回
|
||||
round2.MessageId.Should().NotBeEmpty();
|
||||
round2.Content.Should().NotBeNullOrEmpty();
|
||||
|
||||
// DB: 累计 4 条(2 user + 2 assistant)
|
||||
db.ChatMessages.Should().HaveCount(4);
|
||||
cache.Count(conversationId).Should().Be(4, "两轮后 cache 4 条");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_ConversationNotFound_ThrowsNotFoundException()
|
||||
{
|
||||
// Given: 会话不存在
|
||||
await using var db = CreateDb("not-found");
|
||||
var cache = new StubChatMessageCache();
|
||||
var chatAgent = new StubAIChatAgent();
|
||||
var handler = new SendMessageCommandHandler(db, chatAgent, cache);
|
||||
|
||||
// When/Then: 发消息到不存在的会话
|
||||
var act = async () => await handler.Handle(
|
||||
new SendMessageCommand(Guid.NewGuid(), "内容"),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConversation_UnauthorizedUser_Throws()
|
||||
{
|
||||
// Given: UserId = "system"(未授权)
|
||||
await using var db = CreateDb("unauth");
|
||||
var userContext = new TestCurrentUserContext { UserId = "system" };
|
||||
var handler = new CreateConversationCommandHandler(db, userContext);
|
||||
|
||||
// When/Then
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateConversationCommand("x", null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>("未授权用户不能创建会话");
|
||||
}
|
||||
}
|
||||
142
tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs
Normal file
142
tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using BuildFlow.Application.Document.Queries;
|
||||
using BuildFlow.Application.Document.Handlers;
|
||||
using BuildFlow.Application.Embedding.Commands;
|
||||
using BuildFlow.Application.Embedding.Handlers;
|
||||
using BuildFlow.Application.Chat.Commands;
|
||||
using BuildFlow.Application.Chat.Handlers;
|
||||
using BuildFlow.Application.Chat.Queries;
|
||||
using BuildFlow.Domain.Entities;
|
||||
using BuildFlow.Domain.Enums;
|
||||
using BuildFlow.Domain.Interfaces;
|
||||
using BuildFlow.Infrastructure.Persistence;
|
||||
using BuildFlow.Rag.Tests.Stub;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Rag.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-RAG] Embedding Command + Document/Chat Query Handler 补测。
|
||||
/// 覆盖:EmbedText/EmbedBatch / GetDocuments / GetConversationDetail / GetConversations / DeleteConversation。
|
||||
/// </summary>
|
||||
public class EmbeddingAndQueryTests
|
||||
{
|
||||
private static RagDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "")
|
||||
{
|
||||
var userContext = new TestCurrentUserContext();
|
||||
var options = new DbContextOptionsBuilder<RagDbContext>()
|
||||
.UseInMemoryDatabase($"rag-misc-{name}-{Guid.NewGuid():N}")
|
||||
.AddInterceptors(new AuditInterceptor(userContext))
|
||||
.Options;
|
||||
return new RagDbContext(options);
|
||||
}
|
||||
|
||||
// ===== Embedding =====
|
||||
|
||||
[Fact]
|
||||
public async Task EmbedText_ReturnsVectorAndDimensions()
|
||||
{
|
||||
var embedding = new StubEmbeddingService();
|
||||
var handler = new EmbedTextCommandHandler(embedding);
|
||||
|
||||
var result = await handler.Handle(new EmbedTextCommand("测试文本"), CancellationToken.None);
|
||||
|
||||
result.Vector.Should().HaveCount(StubEmbeddingService.Dimensions);
|
||||
result.Dimensions.Should().Be(StubEmbeddingService.Dimensions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmbedBatch_ReturnsVectorsInOrder()
|
||||
{
|
||||
var embedding = new StubEmbeddingService();
|
||||
var handler = new EmbedBatchCommandHandler(embedding);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new EmbedBatchCommand(["文本A", "文本B", "文本C"]), CancellationToken.None);
|
||||
|
||||
result.Vectors.Should().HaveCount(3);
|
||||
result.Vectors[0].Should().HaveCount(StubEmbeddingService.Dimensions);
|
||||
result.Dimensions.Should().Be(StubEmbeddingService.Dimensions);
|
||||
}
|
||||
|
||||
// ===== Document Queries =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetDocuments_ByKnowledgeBase_ReturnsOnlyMatching()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var kbId = Guid.NewGuid();
|
||||
var otherKbId = Guid.NewGuid();
|
||||
db.Documents.Add(new Document { KnowledgeBaseId = kbId, Title = "doc1", FileName = "a.md", FilePath = "a", ContentType = "text", Status = DocumentStatus.Completed });
|
||||
db.Documents.Add(new Document { KnowledgeBaseId = kbId, Title = "doc2", FileName = "b.md", FilePath = "b", ContentType = "text", Status = DocumentStatus.Pending });
|
||||
db.Documents.Add(new Document { KnowledgeBaseId = otherKbId, Title = "other", FileName = "c.md", FilePath = "c", ContentType = "text", Status = DocumentStatus.Completed });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new GetDocumentsQueryHandler(db);
|
||||
var result = await handler.Handle(new GetDocumentsQuery(kbId), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().OnlyContain(d => d.KnowledgeBaseId == kbId);
|
||||
}
|
||||
|
||||
// ===== Chat Queries =====
|
||||
|
||||
[Fact]
|
||||
public async Task GetConversations_ReturnsCurrentUserOnly()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var userContext = new TestCurrentUserContext();
|
||||
var myId = Guid.Parse(userContext.UserId);
|
||||
db.Conversations.Add(new Conversation { Title = "我的", UserId = myId });
|
||||
db.Conversations.Add(new Conversation { Title = "他人的", UserId = Guid.NewGuid() });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new GetConversationsQueryHandler(db, userContext);
|
||||
var result = await handler.Handle(new GetConversationsQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Title.Should().Be("我的");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConversationDetail_Existing_ReturnsWithMessages()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var convId = Guid.NewGuid();
|
||||
var userId = Guid.Parse(new TestCurrentUserContext().UserId);
|
||||
db.Conversations.Add(new Conversation { Id = convId, Title = "详情", UserId = userId });
|
||||
db.ChatMessages.Add(new ChatMessage { ConversationId = convId, Role = ChatRole.User, Content = "你好" });
|
||||
db.ChatMessages.Add(new ChatMessage { ConversationId = convId, Role = ChatRole.Assistant, Content = "回复" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var cache = new StubChatMessageCache();
|
||||
var handler = new GetConversationDetailQueryHandler(db, cache);
|
||||
var result = await handler.Handle(new GetConversationDetailQuery(convId), CancellationToken.None);
|
||||
|
||||
result.Title.Should().Be("详情");
|
||||
result.Messages.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteConversation_RemovesFromDbAndCache()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var convId = Guid.NewGuid();
|
||||
var userId = Guid.Parse(new TestCurrentUserContext().UserId);
|
||||
db.Conversations.Add(new Conversation { Id = convId, Title = "待删", UserId = userId });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var cache = new StubChatMessageCache();
|
||||
await cache.SetMessagesAsync(convId, [new CachedChatMessage(Guid.NewGuid(), "user", "hi", null, DateTime.UtcNow)]);
|
||||
|
||||
var handler = new DeleteConversationCommandHandler(db, cache);
|
||||
await handler.Handle(new DeleteConversationCommand(convId), CancellationToken.None);
|
||||
|
||||
(await db.Conversations.AnyAsync(c => c.Id == convId)).Should().BeFalse();
|
||||
cache.Count(convId).Should().Be(0, "cache 也应清除");
|
||||
}
|
||||
}
|
||||
121
tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs
Normal file
121
tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs
Normal file
@ -0,0 +1,121 @@
|
||||
using BuildFlow.Application.KnowledgeBase.Commands;
|
||||
using BuildFlow.Application.KnowledgeBase.Handlers;
|
||||
using BuildFlow.Application.KnowledgeBase.Queries;
|
||||
using BuildFlow.Domain.Enums;
|
||||
using BuildFlow.Infrastructure.Persistence;
|
||||
using BuildFlow.Rag.Tests.Stub;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Rag.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// [SOP-RAG] 知识库 CRUD + 列表查询 Handler 全覆盖。
|
||||
/// 飞书矩阵: rag/KnowledgeBase 子域(Create/Update/Delete/GetList 缺口→全补)
|
||||
/// </summary>
|
||||
public class KnowledgeBaseCrudTests
|
||||
{
|
||||
private static RagDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "")
|
||||
{
|
||||
var userContext = new TestCurrentUserContext();
|
||||
var options = new DbContextOptionsBuilder<RagDbContext>()
|
||||
.UseInMemoryDatabase($"rag-kb-{name}-{Guid.NewGuid():N}")
|
||||
.AddInterceptors(new AuditInterceptor(userContext))
|
||||
.Options;
|
||||
return new RagDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Valid_ReturnsDtoWithDefaults()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new CreateKnowledgeBaseCommandHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateKnowledgeBaseCommand("我的知识库", "描述", null, null, null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Name.Should().Be("我的知识库");
|
||||
result.ChunkSize.Should().Be(500, "默认值");
|
||||
result.RetrievalMode.Should().Be((int)RetrievalMode.Hybrid);
|
||||
result.VectorWeight.Should().Be(0.7);
|
||||
(await db.KnowledgeBases.CountAsync()).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_EmptyName_ThrowsBusiness()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new CreateKnowledgeBaseCommandHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateKnowledgeBaseCommand(" ", null, null, null, null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<BusinessException>("空名称应拒绝");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_Existing_OverwritesFields()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var createHandler = new CreateKnowledgeBaseCommandHandler(db);
|
||||
var created = await createHandler.Handle(
|
||||
new CreateKnowledgeBaseCommand("原名", null, null, null, null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
var updateHandler = new UpdateKnowledgeBaseCommandHandler(db);
|
||||
var result = await updateHandler.Handle(
|
||||
new UpdateKnowledgeBaseCommand(created.Id, "新名", "新描述", 800, 100, null, null, null, null, null, null, 0.8, 0.9),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Name.Should().Be("新名");
|
||||
result.ChunkSize.Should().Be(800);
|
||||
result.VectorWeight.Should().Be(0.9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistent_ThrowsNotFound()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var handler = new UpdateKnowledgeBaseCommandHandler(db);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdateKnowledgeBaseCommand(Guid.NewGuid(), "x", null, null, null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Existing_RemovesSoftly()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var createHandler = new CreateKnowledgeBaseCommandHandler(db);
|
||||
var created = await createHandler.Handle(
|
||||
new CreateKnowledgeBaseCommand("待删", null, null, null, null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
var deleteHandler = new DeleteKnowledgeBaseCommandHandler(db);
|
||||
await deleteHandler.Handle(new DeleteKnowledgeBaseCommand(created.Id), CancellationToken.None);
|
||||
|
||||
(await db.KnowledgeBases.AnyAsync(k => k.Id == created.Id)).Should().BeFalse("软删后不可见");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_ReturnsAllActive()
|
||||
{
|
||||
var db = CreateDb();
|
||||
var createHandler = new CreateKnowledgeBaseCommandHandler(db);
|
||||
await createHandler.Handle(new CreateKnowledgeBaseCommand("KB1", null, null, null, null, null, null, null, null, null, null, null, null), CancellationToken.None);
|
||||
await createHandler.Handle(new CreateKnowledgeBaseCommand("KB2", null, null, null, null, null, null, null, null, null, null, null, null), CancellationToken.None);
|
||||
|
||||
var queryHandler = new GetKnowledgeBasesQueryHandler(db);
|
||||
var result = await queryHandler.Handle(new GetKnowledgeBasesQuery(), CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user