using BuildFlow.Shared.Domain.Common; using BuildFlow.Shared.Infrastructure; using File.Application.Features.Files.Handlers; using File.Application.Features.Files.Queries; 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; /// /// 秒传预检测试。验证查重三维度(同用户/跨用户/防碰撞)+ S3 存在性校验(孤儿清理)。 /// public class CheckFileQueryHandlerTests { 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 s3 = new Mock(); // 默认对象存在;个别测试覆盖 false s3.Setup(x => x.ObjectExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName) .AddInterceptors(new AuditInterceptor(user.Object)) .Options; return (new FileDbContext(options), s3, user); } private static FileMeta NewFile(string owner, string hash, long size, string name = "a.txt") => new() { Name = name, S3Key = Guid.NewGuid().ToString(), S3Bucket = "files", Size = size, ContentType = "text/plain", ContentHash = hash, OwnerId = owner, }; [Fact] public async Task Check_同用户同哈希同大小_S3对象存在_命中秒传() { var (db, s3, user) = CreateDeps(nameof(Check_同用户同哈希同大小_S3对象存在_命中秒传)); db.Files.Add(NewFile(OwnerId, Sha, 100)); await db.SaveChangesAsync(); var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 100), CancellationToken.None); result.Exists.Should().BeTrue(); result.File.Should().NotBeNull(); result.File!.Size.Should().Be(100); result.File.OwnerId.Should().Be(OwnerId); } [Fact] public async Task Check_无匹配_返回未命中() { var (db, s3, user) = CreateDeps(nameof(Check_无匹配_返回未命中)); var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 100), CancellationToken.None); result.Exists.Should().BeFalse(); result.File.Should().BeNull(); } [Fact] public async Task Check_他人文件不命中_所有权隔离() { var (db, s3, user) = CreateDeps(nameof(Check_他人文件不命中_所有权隔离)); db.Files.Add(NewFile("another-user", Sha, 100)); await db.SaveChangesAsync(); var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 100), CancellationToken.None); result.Exists.Should().BeFalse("他人文件不应被秒传命中(所有权隔离)"); } [Fact] public async Task Check_大小不匹配不命中_防哈希碰撞() { var (db, s3, user) = CreateDeps(nameof(Check_大小不匹配不命中_防哈希碰撞)); db.Files.Add(NewFile(OwnerId, Sha, 100)); await db.SaveChangesAsync(); var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 999), CancellationToken.None); result.Exists.Should().BeFalse("大小不同不应命中(防哈希碰撞)"); } [Fact] public async Task Check_文件无哈希不命中() { var (db, s3, user) = CreateDeps(nameof(Check_文件无哈希不命中)); db.Files.Add(NewFile(OwnerId, hash: null!, 100)); await db.SaveChangesAsync(); var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 100), CancellationToken.None); result.Exists.Should().BeFalse("无哈希的老文件不参与秒传查重"); } [Fact] public async Task Check_S3对象已丢失_清理孤儿FileMeta_返回未命中() { // 缺陷E回归:FileMeta 存在但 S3 对象被外部删除,应清理孤儿并返回未命中(而非误报秒传成功) var (db, s3, user) = CreateDeps(nameof(Check_S3对象已丢失_清理孤儿FileMeta_返回未命中)); db.Files.Add(NewFile(OwnerId, Sha, 100)); await db.SaveChangesAsync(); s3.Setup(x => x.ObjectExistsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); // S3 对象不存在 var handler = new CheckFileQueryHandler(db, s3.Object, user.Object); var result = await handler.Handle(new CheckFileQuery(Sha, 100), CancellationToken.None); result.Exists.Should().BeFalse("S3 对象丢失不应误报秒传成功"); // 孤儿 FileMeta 应被清理 var remaining = await db.Files.ToListAsync(); remaining.Should().BeEmpty("孤儿 FileMeta 应被清理"); } }