From 7ecd78550f219cec931aba2dcb34d569b4698dda Mon Sep 17 00:00:00 2001 From: xiangning Date: Thu, 9 Jul 2026 12:52:00 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20platform/rag/file?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform: 通知与权限模块 handler 测试 - rag: Chat 端到端、知识库 CRUD、嵌入与查询测试 - file: 文件分享下载与文件夹/桶设置测试 --- .../StartWorkflowInstanceEndpoint.cs | 32 -- .../FoldersBucketsSettingsTests.cs | 293 ++++++++++++++++++ .../ShareDownloadTests.cs | 152 +++++++++ .../Notifications/NotificationHandlerTests.cs | 170 ++++++++++ .../Permissions/PermissionHandlerTests.cs | 190 ++++++++++++ .../BuildFlow.Rag.Tests/ChatEndToEndTests.cs | 142 +++++++++ .../EmbeddingAndQueryTests.cs | 142 +++++++++ .../KnowledgeBaseCrudTests.cs | 121 ++++++++ 8 files changed, 1210 insertions(+), 32 deletions(-) delete mode 100644 src/BuildFlow.Host/Endpoints/workflow/WorkflowInstance/StartWorkflowInstanceEndpoint.cs create mode 100644 tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs create mode 100644 tests/BuildFlow.File.Tests/ShareDownloadTests.cs create mode 100644 tests/BuildFlow.Platform.Tests/Notifications/NotificationHandlerTests.cs create mode 100644 tests/BuildFlow.Platform.Tests/Permissions/PermissionHandlerTests.cs create mode 100644 tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs create mode 100644 tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs create mode 100644 tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs diff --git a/src/BuildFlow.Host/Endpoints/workflow/WorkflowInstance/StartWorkflowInstanceEndpoint.cs b/src/BuildFlow.Host/Endpoints/workflow/WorkflowInstance/StartWorkflowInstanceEndpoint.cs deleted file mode 100644 index 208d7d0..0000000 --- a/src/BuildFlow.Host/Endpoints/workflow/WorkflowInstance/StartWorkflowInstanceEndpoint.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MediatR; -using Workflow.Application.Features.WorkflowInstances.Commands; - -namespace BuildFlow.Host.Endpoints.Workflow.WorkflowInstance; - - -/// -/// 发起流程实例端点。POST /instances,按定义创建实例并产生首个 token。 -/// -public class StartWorkflowInstanceEndpoint : Endpoint -{ - 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); - } -} diff --git a/tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs b/tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs new file mode 100644 index 0000000..6eddddd --- /dev/null +++ b/tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs @@ -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; + +/// +/// [SOP-FILE] Folders(非事务部分) + Buckets + Settings + Shares(Create/Delete/Info) 子域补测。 +/// 注:DeleteFolder/UploadToFolder 涉及事务或补偿,InMemory 支持有限,此处覆盖不依赖事务的核心路径。 +/// 飞书矩阵: file/Folders+Buckets+Settings+Shares 缺口→补 +/// +public class FoldersBucketsSettingsTests +{ + private const string OwnerId = "user-001"; + + private static (FileDbContext db, Mock s3, Mock user, Mock mediator) CreateDeps(string name) + { + var user = new Mock(); + user.SetupGet(u => u.UserId).Returns(OwnerId); + user.SetupGet(u => u.IPAddress).Returns("127.0.0.1"); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"file-sub-{name}-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(user.Object)) + .Options; + return (new FileDbContext(options), new Mock(), user, new Mock()); + } + + // ===== 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().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().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().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(); + 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()), Times.Once); + } + + [Fact] + public async Task DeleteBucket_CallsS3Delete() + { + var s3 = new Mock(); + 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()), Times.Once); + } + + [Fact] + public async Task ListBuckets_ReturnsS3List() + { + var s3 = new Mock(); + s3.Setup(x => x.ListBucketsAsync(It.IsAny())) + .ReturnsAsync(new List { "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("非法 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(); + 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("非 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(); + } +} diff --git a/tests/BuildFlow.File.Tests/ShareDownloadTests.cs b/tests/BuildFlow.File.Tests/ShareDownloadTests.cs new file mode 100644 index 0000000..c2d0de2 --- /dev/null +++ b/tests/BuildFlow.File.Tests/ShareDownloadTests.cs @@ -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; + +/// +/// [SOP-FILE-02] 分享下载端到端测试。 +/// 覆盖:正常下载/过期/次数耗尽/密码校验。 +/// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/GeOwweCmfi1yMLk62iIcOKLInOE +/// +public class ShareDownloadTests +{ + private static (FileDbContext db, Mock s3, Mock mediator) CreateDeps(string dbName) + { + var user = new Mock(); + user.SetupGet(u => u.UserId).Returns("user-001"); + user.SetupGet(u => u.IPAddress).Returns("127.0.0.1"); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"share-{dbName}-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(user.Object)) + .Options; + var db = new FileDbContext(options); + + var s3 = new Mock(); + s3.Setup(x => x.GeneratePresignedUrlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("https://presigned.example.com/file"); + + return (db, s3, new Mock()); + } + + 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.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("过期分享应被拒绝"); + } + + [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("次数耗尽应被拒绝"); + } + + [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("无密码访问受保护分享应被拒绝"); + } + + [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("密码错误应被拒绝"); + } + + [Fact] + public async Task Download_NonExistentToken_ThrowsNotFound() + { + var (db, s3, mediator) = CreateDeps("not-found"); + var handler = new DownloadShareQueryHandler(db, s3.Object, mediator.Object, NullLogger.Instance); + + var act = async () => await handler.Handle( + new DownloadShareQuery(UniqueToken("nope"), null), CancellationToken.None); + + await act.Should().ThrowAsync("不存在的 token 应返回 NotFound"); + } +} diff --git a/tests/BuildFlow.Platform.Tests/Notifications/NotificationHandlerTests.cs b/tests/BuildFlow.Platform.Tests/Notifications/NotificationHandlerTests.cs new file mode 100644 index 0000000..6c1703d --- /dev/null +++ b/tests/BuildFlow.Platform.Tests/Notifications/NotificationHandlerTests.cs @@ -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; + +/// +/// [SOP-PLATFORM-02] 通知系统全 Handler 覆盖测试。 +/// 覆盖:Publish(Fanout) / GetNotifications(分页) / GetUnreadCount / MarkRead / MarkAllRead。 +/// 飞书矩阵: platform/Notifications 子域(5 Handler 零覆盖→全补) +/// +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(); + 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>(l => l.Count == 2), + Arg.Any()); + } + + [Fact] + public async Task Publish_NoRecipients_ReturnsZeroAndNoDispatch() + { + var db = CreateDb(); + var dispatcher = Substitute.For(); + 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); + } +} diff --git a/tests/BuildFlow.Platform.Tests/Permissions/PermissionHandlerTests.cs b/tests/BuildFlow.Platform.Tests/Permissions/PermissionHandlerTests.cs new file mode 100644 index 0000000..e9bf92a --- /dev/null +++ b/tests/BuildFlow.Platform.Tests/Permissions/PermissionHandlerTests.cs @@ -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; + +/// +/// [SOP-PLATFORM] 权限管理全 Handler 覆盖测试。 +/// 覆盖:Create / Update / Delete / GetList / GetById / GetTree。 +/// 飞书矩阵: platform/Permissions 子域(6 Handler 零覆盖→全补) +/// +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("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(); + } + + [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(); + } + + [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(); + } + + [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("未分组"); + } +} diff --git a/tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs b/tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs new file mode 100644 index 0000000..5401966 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/ChatEndToEndTests.cs @@ -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; + +/// +/// [SOP-RAG-02] 多轮对话端到端测试。 +/// 不依赖 pgvector。验证:CreateConversation → SendMessage(两轮)→ 历史拼装 → cache 一致性。 +/// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/CBrcwbDH6ialzFkF1cgcnvKenHM +/// +public class ChatEndToEndTests +{ + private static RagDbContext CreateDb(string name) + { + // 挂生产 AuditInterceptor,自动填充 CreatedBy/UpdatedBy/OperatorIP 等审计字段 + // (InMemory 默认无拦截器,导致必填审计字段为空抛 DbUpdateException) + var userContext = new TestCurrentUserContext(); + var options = new DbContextOptionsBuilder() + .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(); + } + + [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("未授权用户不能创建会话"); + } +} diff --git a/tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs b/tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs new file mode 100644 index 0000000..5e3bf0c --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/EmbeddingAndQueryTests.cs @@ -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; + +/// +/// [SOP-RAG] Embedding Command + Document/Chat Query Handler 补测。 +/// 覆盖:EmbedText/EmbedBatch / GetDocuments / GetConversationDetail / GetConversations / DeleteConversation。 +/// +public class EmbeddingAndQueryTests +{ + private static RagDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "") + { + var userContext = new TestCurrentUserContext(); + var options = new DbContextOptionsBuilder() + .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 也应清除"); + } +} diff --git a/tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs b/tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs new file mode 100644 index 0000000..bdc08c3 --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/KnowledgeBaseCrudTests.cs @@ -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; + +/// +/// [SOP-RAG] 知识库 CRUD + 列表查询 Handler 全覆盖。 +/// 飞书矩阵: rag/KnowledgeBase 子域(Create/Update/Delete/GetList 缺口→全补) +/// +public class KnowledgeBaseCrudTests +{ + private static RagDbContext CreateDb([System.Runtime.CompilerServices.CallerMemberName] string name = "") + { + var userContext = new TestCurrentUserContext(); + var options = new DbContextOptionsBuilder() + .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("空名称应拒绝"); + } + + [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(); + } + + [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); + } +}