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(); } }