diff --git a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs index 63181cc..6e67995 100644 --- a/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs +++ b/src/modules/file/Application/Features/Files/Handlers/UploadFileCommandHandler.cs @@ -49,6 +49,7 @@ public class UploadFileCommandHandler( S3Key = req.ObjectKey, S3Bucket = bucket, Size = req.Size, + SizeVerified = true, ContentType = string.IsNullOrEmpty(req.ContentType) ? "application/octet-stream" : req.ContentType, ContentHash = req.ContentHash, OwnerId = owner, diff --git a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs index 459b79f..6ed0fab 100644 --- a/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs +++ b/src/modules/file/Application/Features/Folders/Handlers/UploadToFolderCommandHandler.cs @@ -47,6 +47,7 @@ public class UploadToFolderCommandHandler( S3Key = s3Key, S3Bucket = bucket, Size = req.Size, + SizeVerified = true, ContentType = contentType, ContentHash = req.ContentHash, OwnerId = owner, diff --git a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs index 0b2e17e..252acae 100644 --- a/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs +++ b/src/modules/file/Application/Features/Multipart/Handlers/CompleteMultipartCommandHandler.cs @@ -56,6 +56,7 @@ public class CompleteMultipartCommandHandler( S3Key = session.ObjectKey, S3Bucket = session.BucketName, Size = req.Size, + SizeVerified = true, ContentType = "application/octet-stream", ContentHash = session.ContentHash, OwnerId = session.OwnerId, diff --git a/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs b/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs index d84e20c..56b96e1 100644 --- a/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs +++ b/src/modules/file/Domain/Entities/FileMeta/FileMetaEntity.cs @@ -20,6 +20,9 @@ public class FileMetaEntity : BaseEntity, IAuditable, IHasOperatorIP public long Size { get; set; } + /// 对象大小是否已由上传结果或后台 S3 查询确认。 + public bool SizeVerified { get; set; } + /// 文件内容 SHA-256 哈希(前端计算),秒传查重的依据。可空(老数据/老客户端兼容)。 public string? ContentHash { get; set; } diff --git a/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs new file mode 100644 index 0000000..cb0e58e --- /dev/null +++ b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.Designer.cs @@ -0,0 +1,365 @@ +// +using System; +using File.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace File.Infrastructure.Migrations +{ + [DbContext(typeof(FileDbContext))] + [Migration("20260724020220_AddFileSizeVerified")] + partial class AddFileSizeVerified + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("File.Domain.Entities.FileMeta.FileMetaEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("文件元数据ID"); + + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("文件内容SHA-256哈希,秒传查重依据"); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasDefaultValue("application/octet-stream") + .HasComment("MIME类型"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("创建人"); + + b.Property("FolderId") + .HasColumnType("uuid") + .HasComment("所属文件夹ID,null表示根目录散落文件"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("文件名"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("操作者IP"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasComment("所有者用户Id(JWT sub)"); + + b.Property("S3Bucket") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("S3存储桶名"); + + b.Property("S3Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("S3对象key(UploadToFolder恒用UUID)"); + + b.Property("Size") + .HasColumnType("bigint") + .HasComment("文件大小(字节)"); + + b.Property("SizeVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("size_verified") + .HasDefaultValue(false) + .HasComment("文件大小是否已确认"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最后修改时间(UTC)"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("最后修改人"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("S3Key"); + + b.HasIndex("OwnerId", "ContentHash") + .IsUnique(); + + b.HasIndex("SizeVerified", "CreatedAt") + .HasDatabaseName("ix_b_files_size_verified_created_at"); + + b.ToTable("b_files", (string)null); + }); + + modelBuilder.Entity("File.Domain.Entities.FileSetting.FileSettingEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasComment("固定为1,保证单行"); + + b.Property("DefaultBucket") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("默认存储桶名"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最后修改时间(UTC)"); + + b.HasKey("Id"); + + b.ToTable("b_file_settings", (string)null); + }); + + modelBuilder.Entity("File.Domain.Entities.Folder.FolderEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("文件夹ID"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("创建人"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("文件夹名称(1-255,禁/\\控制字符)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("操作者IP"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasComment("所有者用户Id(JWT sub)"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasComment("父文件夹ID,null表示根目录"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最后修改时间(UTC)"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("最后修改人"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.ToTable("b_folders", (string)null); + }); + + modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession.MultipartUploadSessionEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("会话ID"); + + b.Property("BucketName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("S3存储桶名"); + + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("文件内容SHA-256哈希,仅记录用途"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("创建人"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("S3对象key(断点续传用内容哈希派生稳定值)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("操作者IP"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasComment("所有者用户Id(JWT sub)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("会话状态:active/completed/aborted"); + + b.Property("TotalParts") + .HasColumnType("integer") + .HasComment("预计分片总数,进度参考"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最后修改时间(UTC)"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("最后修改人"); + + b.Property("UploadId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("S3 multipart upload的UploadId,续传依据"); + + b.HasKey("Id"); + + b.HasIndex("UploadId") + .IsUnique(); + + b.HasIndex("OwnerId", "BucketName", "ObjectKey"); + + b.ToTable("b_multipart_upload_sessions", (string)null); + }); + + modelBuilder.Entity("File.Domain.Entities.ShareLink.ShareLinkEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasComment("分享链接ID"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("创建人(即分享创建者,用于所有权隔离)"); + + b.Property("DownloadCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("已下载次数"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(UTC),null表示永不过期"); + + b.Property("MaxDownloads") + .HasColumnType("integer") + .HasComment("最大下载次数,null表示不限"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasComment("操作者IP"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComment("bcrypt密码哈希,null表示无需密码"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasComment("被分享资源ID"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasComment("资源类型:folder/file"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("分享token(16字节随机hex),唯一索引"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最后修改时间(UTC)"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasComment("最后修改人"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("b_share_links", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs new file mode 100644 index 0000000..1ee3974 --- /dev/null +++ b/src/modules/file/Infrastructure/Migrations/20260724020220_AddFileSizeVerified.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace File.Infrastructure.Migrations +{ + /// + public partial class AddFileSizeVerified : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "size_verified", + table: "b_files", + type: "boolean", + nullable: false, + defaultValue: false, + comment: "文件大小是否已确认"); + + migrationBuilder.Sql("UPDATE b_files SET size_verified = TRUE WHERE \"Size\" > 0"); + + migrationBuilder.CreateIndex( + name: "ix_b_files_size_verified_created_at", + table: "b_files", + columns: new[] { "size_verified", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_b_files_size_verified_created_at", + table: "b_files"); + + migrationBuilder.DropColumn( + name: "size_verified", + table: "b_files"); + } + } +} diff --git a/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs b/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs index a66ff6a..898ecb9 100644 --- a/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs +++ b/src/modules/file/Infrastructure/Migrations/FileDbContextModelSnapshot.cs @@ -22,7 +22,7 @@ namespace BuildFlow.File.Infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("File.Domain.Entities.FileMeta", b => + modelBuilder.Entity("File.Domain.Entities.FileMeta.FileMetaEntity", b => { b.Property("Id") .HasColumnType("uuid") @@ -89,6 +89,13 @@ namespace BuildFlow.File.Infrastructure.Migrations .HasColumnType("bigint") .HasComment("文件大小(字节)"); + b.Property("SizeVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasColumnName("size_verified") + .HasDefaultValue(false) + .HasComment("文件大小是否已确认"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasComment("最后修改时间(UTC)"); @@ -110,10 +117,13 @@ namespace BuildFlow.File.Infrastructure.Migrations b.HasIndex("OwnerId", "ContentHash") .IsUnique(); + b.HasIndex("SizeVerified", "CreatedAt") + .HasDatabaseName("ix_b_files_size_verified_created_at"); + b.ToTable("b_files", (string)null); }); - modelBuilder.Entity("File.Domain.Entities.FileSetting", b => + modelBuilder.Entity("File.Domain.Entities.FileSetting.FileSettingEntity", b => { b.Property("Id") .HasColumnType("integer") @@ -134,7 +144,7 @@ namespace BuildFlow.File.Infrastructure.Migrations b.ToTable("b_file_settings", (string)null); }); - modelBuilder.Entity("File.Domain.Entities.Folder", b => + modelBuilder.Entity("File.Domain.Entities.Folder.FolderEntity", b => { b.Property("Id") .HasColumnType("uuid") @@ -191,7 +201,7 @@ namespace BuildFlow.File.Infrastructure.Migrations b.ToTable("b_folders", (string)null); }); - modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession", b => + modelBuilder.Entity("File.Domain.Entities.MultipartUploadSession.MultipartUploadSessionEntity", b => { b.Property("Id") .HasColumnType("uuid") @@ -272,7 +282,7 @@ namespace BuildFlow.File.Infrastructure.Migrations b.ToTable("b_multipart_upload_sessions", (string)null); }); - modelBuilder.Entity("File.Domain.Entities.ShareLink", b => + modelBuilder.Entity("File.Domain.Entities.ShareLink.ShareLinkEntity", b => { b.Property("Id") .HasColumnType("uuid") diff --git a/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs b/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs index 7f0fc2d..6597d76 100644 --- a/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs +++ b/src/modules/file/Infrastructure/Persistence/Configurations/FileMetaConfiguration.cs @@ -20,6 +20,7 @@ public class FileMetaConfiguration : IEntityTypeConfiguration builder.Property(e => e.S3Key).HasMaxLength(512).IsRequired().HasComment("S3对象key(UploadToFolder恒用UUID)"); builder.Property(e => e.S3Bucket).HasMaxLength(255).IsRequired().HasComment("S3存储桶名"); builder.Property(e => e.Size).HasComment("文件大小(字节)"); + builder.Property(e => e.SizeVerified).HasColumnName("size_verified").HasDefaultValue(false).HasComment("文件大小是否已确认"); builder.Property(e => e.ContentType).HasMaxLength(255).HasDefaultValue("application/octet-stream").HasComment("MIME类型"); builder.Property(e => e.OwnerId).HasMaxLength(36).IsRequired().HasComment("所有者用户Id(JWT sub)"); builder.Property(e => e.ContentHash).HasMaxLength(128).HasComment("文件内容SHA-256哈希,秒传查重依据"); @@ -33,6 +34,8 @@ public class FileMetaConfiguration : IEntityTypeConfiguration builder.HasIndex(e => e.FolderId); builder.HasIndex(e => e.S3Key); builder.HasIndex(e => e.OwnerId); + builder.HasIndex(e => new { e.SizeVerified, e.CreatedAt }) + .HasDatabaseName("ix_b_files_size_verified_created_at"); // 秒传查重主路径:(OwnerId, ContentHash) 复合唯一索引。 // 唯一约束是并发安全的根本保证:应用层"查+插"存在 TOCTOU 窗口,唯有 DB 唯一约束能在 // 并发上传同一文件时真正阻止重复 FileMetaEntity + S3 孤儿对象。PostgreSQL 唯一索引允许多个 NULL, diff --git a/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs b/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs new file mode 100644 index 0000000..ee26f17 --- /dev/null +++ b/tests/BuildFlow.File.Tests/FileSizeVerifiedTests.cs @@ -0,0 +1,141 @@ +using BuildFlow.Shared.Domain.Common; +using BuildFlow.Shared.Infrastructure; +using File.Application.Common; +using File.Application.Features.Files.Commands; +using File.Application.Features.Files.Handlers; +using File.Application.Features.Folders.Commands; +using File.Application.Features.Folders.Handlers; +using File.Application.Features.Multipart.Commands; +using File.Application.Features.Multipart.DTOs; +using File.Application.Features.Multipart.Handlers; +using File.Domain.Entities.FileMeta; +using File.Domain.Entities.MultipartUploadSession; +using File.Domain.Values; +using File.Infrastructure.Persistence; +using File.Infrastructure.Storage; +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Moq; +using Xunit; + +namespace BuildFlow.File.Tests; + +/// 文件大小确认状态在上传路径与 EF 模型中的持久化契约测试。 +public class FileSizeVerifiedTests +{ + private const string OwnerId = "user-001"; + private const string Bucket = "files"; + + [Fact] + public async Task DirectZeroByteUploadMarksSizeVerified() + { + var (db, s3, user) = CreateDependencies(); + var handler = new UploadFileCommandHandler( + s3.Object, + db, + user.Object, + CreateBucketResolver(), + new Mock().Object); + + await handler.Handle( + new UploadFileCommand("empty.txt", Stream.Null, "text/plain", 0), + CancellationToken.None); + + var file = await db.Files.SingleAsync(); + file.Size.Should().Be(0); + file.SizeVerified.Should().BeTrue(); + } + + [Fact] + public async Task FolderUploadMarksSizeVerified() + { + var (db, s3, user) = CreateDependencies(); + var handler = new UploadToFolderCommandHandler( + db, + s3.Object, + user.Object, + CreateBucketResolver()); + + await handler.Handle( + new UploadToFolderCommand( + Guid.NewGuid(), + "report.pdf", + new MemoryStream([1, 2, 3]), + "application/pdf", + 3), + CancellationToken.None); + + (await db.Files.SingleAsync()).SizeVerified.Should().BeTrue(); + } + + [Fact] + public async Task CompletedMultipartUploadMarksSizeVerified() + { + var (db, s3, user) = CreateDependencies(); + db.UploadSessions.Add(new MultipartUploadSessionEntity + { + BucketName = Bucket, + ObjectKey = "multipart-key", + UploadId = "upload-1", + OwnerId = OwnerId, + Status = UploadSessionStatus.Active, + }); + await db.SaveChangesAsync(); + var handler = new CompleteMultipartCommandHandler( + s3.Object, + db, + user.Object, + CreateBucketResolver()); + + await handler.Handle( + new CompleteMultipartCommand( + "multipart-key", + "upload-1", + [new CompletedPartDto(1, "etag-1")], + "archive.zip", + 1024), + CancellationToken.None); + + (await db.Files.SingleAsync()).SizeVerified.Should().BeTrue(); + } + + [Fact] + public void ModelHasPendingSizeVerificationIndex() + { + var (db, _, _) = CreateDependencies(); + var entity = db.Model.FindEntityType(typeof(FileMetaEntity)); + + entity.Should().NotBeNull(); + var sizeVerified = entity!.FindProperty(nameof(FileMetaEntity.SizeVerified)); + sizeVerified.Should().NotBeNull(); + sizeVerified!.GetColumnName(StoreObjectIdentifier.Table("b_files", null)) + .Should().Be("size_verified"); + entity!.GetIndexes().Should().Contain(index => + index.Properties.Select(property => property.Name) + .SequenceEqual(new[] { nameof(FileMetaEntity.SizeVerified), nameof(FileMetaEntity.CreatedAt) })); + } + + private static (FileDbContext Db, Mock S3, Mock User) + CreateDependencies() + { + var user = new Mock(); + user.SetupGet(context => context.UserId).Returns(OwnerId); + user.SetupGet(context => context.IPAddress).Returns("127.0.0.1"); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"file-size-verified-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(user.Object)) + .Options; + + return (new FileDbContext(options), new Mock(), user); + } + + private static IBucketResolver CreateBucketResolver() + { + var resolver = new Mock(); + resolver.Setup(x => x.GetDefaultBucketAsync(It.IsAny())) + .ReturnsAsync(Bucket); + return resolver.Object; + } +}