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.FileMeta;
using File.Domain.Entities.FileSetting;
using File.Domain.Entities.Folder;
using File.Domain.Entities.MultipartUploadSession;
using File.Domain.Entities.ShareLink;
using File.Domain.Values;
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 FileMetaEntity 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回归:FileMetaEntity 存在但 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 对象丢失不应误报秒传成功");
// 孤儿 FileMetaEntity 应被清理
var remaining = await db.Files.ToListAsync();
remaining.Should().BeEmpty("孤儿 FileMetaEntity 应被清理");
}
}