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 已落库,再次分片上传同文件应秒传");
}
}