build-flow.api/tests/BuildFlow.File.Tests/FoldersBucketsSettingsTests.cs
2026-07-24 10:22:05 +08:00

408 lines
17 KiB
C#

using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Domain.Exceptions;
using BuildFlow.Shared.Infrastructure;
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.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.FileMeta;
using File.Domain.Entities.ShareLink;
using File.Infrastructure.Persistence;
using File.Infrastructure.Storage;
using FluentAssertions;
using MediatR;
using Microsoft.EntityFrameworkCore;
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 FileMetaEntity { 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 FileMetaEntity { Name = "root1.txt", S3Key = "k1", S3Bucket = "b", ContentType = "text", OwnerId = OwnerId, Size = 100 });
db.Files.Add(new FileMetaEntity { Name = "other.txt", S3Key = "k2", S3Bucket = "b", ContentType = "text", OwnerId = "other-user", Size = 50 });
await db.SaveChangesAsync();
var handler = CreateHandler<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 = CreateHandler<GetFolderTreeQueryHandler>(db, user.Object, s3.Object);
var result = await handler.Handle(new GetFolderTreeQuery(), CancellationToken.None);
result.Should().HaveCount(2);
}
[Fact]
public async Task GetRootFiles_DoesNotRepairUnverifiedSizes()
{
var (db, s3, user, _) = CreateDeps("root-read-only");
db.Files.Add(new FileMetaEntity
{
Name = "pending.txt",
S3Key = "pending-root",
S3Bucket = "files",
ContentType = "text/plain",
OwnerId = OwnerId,
Size = 0,
SizeVerified = false,
});
await db.SaveChangesAsync();
s3.Setup(x => x.GetObjectSizeAsync("files", "pending-root", It.IsAny<CancellationToken>()))
.ReturnsAsync(123L);
var handler = CreateHandler<GetRootFilesQueryHandler>(db, user.Object, s3.Object);
var result = await handler.Handle(new GetRootFilesQuery(), CancellationToken.None);
result.Should().ContainSingle().Which.Size.Should().Be(0);
s3.Verify(x => x.GetObjectSizeAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
db.ChangeTracker.Clear();
(await db.Files.SingleAsync()).Size.Should().Be(0);
}
[Fact]
public async Task GetFolder_DoesNotRepairUnverifiedSizes()
{
var (db, s3, user, _) = CreateDeps("folder-read-only");
var folder = await new CreateFolderCommandHandler(db, user.Object, new Mock<IMediator>().Object)
.Handle(new CreateFolderCommand(null, "待修复"), CancellationToken.None);
db.Files.Add(new FileMetaEntity
{
FolderId = folder.Id,
Name = "pending.txt",
S3Key = "pending-folder",
S3Bucket = "files",
ContentType = "text/plain",
OwnerId = OwnerId,
Size = 0,
SizeVerified = false,
});
await db.SaveChangesAsync();
s3.Setup(x => x.GetObjectSizeAsync("files", "pending-folder", It.IsAny<CancellationToken>()))
.ReturnsAsync(123L);
var handler = CreateHandler<GetFolderQueryHandler>(db, user.Object, s3.Object);
var result = await handler.Handle(new GetFolderQuery(folder.Id), CancellationToken.None);
result.Should().NotBeNull();
result!.Files.Should().ContainSingle().Which.Size.Should().Be(0);
s3.Verify(x => x.GetObjectSizeAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
db.ChangeTracker.Clear();
(await db.Files.SingleAsync()).Size.Should().Be(0);
}
[Fact]
public async Task GetFolderTree_DoesNotRepairUnverifiedSizes()
{
var (db, s3, user, _) = CreateDeps("tree-read-only");
var folder = await new CreateFolderCommandHandler(db, user.Object, new Mock<IMediator>().Object)
.Handle(new CreateFolderCommand(null, "待修复"), CancellationToken.None);
db.Files.Add(new FileMetaEntity
{
FolderId = folder.Id,
Name = "pending.txt",
S3Key = "pending-tree",
S3Bucket = "files",
ContentType = "text/plain",
OwnerId = OwnerId,
Size = 0,
SizeVerified = false,
});
await db.SaveChangesAsync();
s3.Setup(x => x.GetObjectSizeAsync("files", "pending-tree", It.IsAny<CancellationToken>()))
.ReturnsAsync(123L);
var handler = CreateHandler<GetFolderTreeQueryHandler>(db, user.Object, s3.Object);
var result = await handler.Handle(new GetFolderTreeQuery(), CancellationToken.None);
result.Should().ContainSingle().Which.Size.Should().Be(0);
s3.Verify(x => x.GetObjectSizeAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
db.ChangeTracker.Clear();
(await db.Files.SingleAsync()).Size.Should().Be(0);
}
// ===== 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 FileMetaEntity { 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 ShareLinkEntity { 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 ShareLinkEntity { 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 FileMetaEntity { Id = fileId, Name = "shared.pdf", S3Key = "k", S3Bucket = "b", ContentType = "application/pdf", OwnerId = OwnerId, Size = 2048 });
db.ShareLinks.Add(new ShareLinkEntity { 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();
}
private static THandler CreateHandler<THandler>(
FileDbContext db,
ICurrentUserContext currentUser,
IS3StorageService s3)
where THandler : notnull
{
var dependencies = new Dictionary<Type, object>
{
[typeof(FileDbContext)] = db,
[typeof(ICurrentUserContext)] = currentUser,
[typeof(IS3StorageService)] = s3,
};
var constructor = typeof(THandler).GetConstructors().Single();
var arguments = constructor.GetParameters()
.Select(parameter => dependencies[parameter.ParameterType])
.ToArray();
return (THandler)constructor.Invoke(arguments);
}
}