using BuildFlow.Shared.Domain.Common; using BuildFlow.Shared.Infrastructure; using File.Application.Features.Multipart.Commands; using File.Application.Features.Multipart.DTOs; using File.Application.Features.Multipart.Handlers; using File.Domain.Entities; using File.Infrastructure.Persistence; using File.Infrastructure.Storage; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace BuildFlow.File.Tests; /// /// 秒传在分片上传路径的端到端测试。覆盖缺陷 2、3 的修复: /// - InitMultipart 命中同用户同哈希同大小文件 → Instant=true(缺陷3:三元组判定) /// - CompleteMultipart 完成后落 FileMeta(缺陷2:分片秒传数据来源) /// 两者串联验证:分片上传完成后,再次 init 同文件能命中秒传。 /// public class InstantUploadTests { private const string OwnerId = "user-001"; private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; private static (FileDbContext db, Mock s3, Mock user) CreateDeps(string dbName) { 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(dbName) .AddInterceptors(new AuditInterceptor(user.Object)) .Options; return (new FileDbContext(options), new Mock(), user); } [Fact] public async Task Init_同用户同哈希同大小文件存在_返回Instant秒传() { var (db, s3, user) = CreateDeps(nameof(Init_同用户同哈希同大小文件存在_返回Instant秒传)); // 预置一条已存在的文件(模拟此前上传完成) db.Files.Add(new FileMeta { Name = "a.bin", S3Key = "k1", S3Bucket = "files", Size = 1024, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = OwnerId, }); await db.SaveChangesAsync(); var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object); var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None); result.Instant.Should().BeTrue("同用户同哈希同大小应命中秒传"); result.UploadId.Should().BeEmpty("秒传不需要 uploadId"); result.Resume.Should().BeFalse(); // 秒传命中不应发起 S3 multipart s3.Verify(x => x.CreateMultipartUploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Init_同哈希但大小不同_不命中秒传_走正常init() { // 缺陷3回归:Size 防碰撞。库里 1024,请求 2048,哈希相同也不应误判秒传。 var (db, s3, user) = CreateDeps(nameof(Init_同哈希但大小不同_不命中秒传_走正常init)); db.Files.Add(new FileMeta { Name = "a.bin", S3Key = "k1", S3Bucket = "files", Size = 1024, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = OwnerId, }); await db.SaveChangesAsync(); s3.Setup(x => x.CreateMultipartUploadAsync("files", "obj", default)).ReturnsAsync("uid-1"); var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object); var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 2048), CancellationToken.None); result.Instant.Should().BeFalse("大小不同不应命中秒传"); result.UploadId.Should().Be("uid-1"); } [Fact] public async Task Init_他人文件不命中秒传_所有权隔离() { var (db, s3, user) = CreateDeps(nameof(Init_他人文件不命中秒传_所有权隔离)); db.Files.Add(new FileMeta { Name = "a.bin", S3Key = "k1", S3Bucket = "files", Size = 1024, ContentType = "application/octet-stream", ContentHash = Sha, OwnerId = "another-user", }); await db.SaveChangesAsync(); s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("uid-1"); var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object); var result = await handler.Handle(new InitMultipartCommand("files", "obj", Sha, TotalParts: 1, Size: 1024), CancellationToken.None); result.Instant.Should().BeFalse("他人文件不参与当前用户秒传"); } [Fact] public async Task Complete_成功后落FileMeta_使后续分片秒传可命中() { // 缺陷2核心回归:分片完成必须落 FileMeta,否则同文件再次分片上传永远命中不了秒传。 var (db, s3, user) = CreateDeps(nameof(Complete_成功后落FileMeta_使后续分片秒传可命中)); // 预置一个 Active 会话(带 ContentHash) db.UploadSessions.Add(new MultipartUploadSession { BucketName = "files", ObjectKey = "obj-key", UploadId = "uid-1", ContentHash = Sha, OwnerId = OwnerId, Status = UploadSessionStatus.Active, }); await db.SaveChangesAsync(); var parts = new List { new(1, "etag-1"), new(2, "etag-2"), }; var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object); var result = await handler.Handle(new CompleteMultipartCommand("files", "obj-key", "uid-1", parts, FileName: "a.bin", Size: 1024), CancellationToken.None); result.Location.Should().Be("files/obj-key"); // FileMeta 应被创建 var meta = await db.Files.SingleOrDefaultAsync(); meta.Should().NotBeNull("分片完成后应落 FileMeta 供秒传查重"); meta!.ContentHash.Should().Be(Sha); meta.Size.Should().Be(1024); meta.Name.Should().Be("a.bin"); meta.OwnerId.Should().Be(OwnerId); // 会话应标记为 Completed var session = await db.UploadSessions.SingleAsync(); session.Status.Should().Be(UploadSessionStatus.Completed); // 串联验证:再次 init 同文件现在能命中秒传 var initHandler = new InitMultipartCommandHandler(s3.Object, db, user.Object); var initResult = await initHandler.Handle(new InitMultipartCommand("files", "obj2", Sha, TotalParts: 2, Size: 1024), CancellationToken.None); initResult.Instant.Should().BeTrue("FileMeta 已落库,再次分片上传同文件应秒传"); } }