143 lines
6.6 KiB
C#
143 lines
6.6 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 秒传在分片上传路径的端到端测试。覆盖缺陷 2、3 的修复:
|
||
/// - InitMultipart 命中同用户同哈希同大小文件 → Instant=true(缺陷3:三元组判定)
|
||
/// - CompleteMultipart 完成后落 FileMeta(缺陷2:分片秒传数据来源)
|
||
/// 两者串联验证:分片上传完成后,再次 init 同文件能命中秒传。
|
||
/// </summary>
|
||
public class InstantUploadTests
|
||
{
|
||
private const string OwnerId = "user-001";
|
||
private const string Sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||
|
||
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<ICurrentUserContext> user) CreateDeps(string dbName)
|
||
{
|
||
var user = new Mock<ICurrentUserContext>();
|
||
user.SetupGet(u => u.UserId).Returns(OwnerId);
|
||
user.SetupGet(u => u.IPAddress).Returns("127.0.0.1");
|
||
var options = new DbContextOptionsBuilder<FileDbContext>()
|
||
.UseInMemoryDatabase(dbName)
|
||
.AddInterceptors(new AuditInterceptor(user.Object))
|
||
.Options;
|
||
return (new FileDbContext(options), new Mock<IS3StorageService>(), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).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<CompletedPartDto>
|
||
{
|
||
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 已落库,再次分片上传同文件应秒传");
|
||
}
|
||
}
|