using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Domain.Exceptions;
using BuildFlow.Shared.Infrastructure;
using File.Application.Common;
using File.Application.Features.Multipart.Commands;
using File.Application.Features.Multipart.DTOs;
using File.Application.Features.Multipart.Handlers;
using File.Application.Features.Multipart.Queries;
using File.Domain.Entities.MultipartUploadSession;
using File.Domain.Values;
using File.Domain.Exceptions;
using File.Infrastructure.Persistence;
using File.Infrastructure.Storage;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace BuildFlow.File.Tests;
///
/// 断点续传会话核心逻辑测试。建立项目首个用 EF InMemory + 真实 FileDbContext 的测试模式:
/// 每个测试用独立库名隔离,S3 用 Moq,CurrentUserContext 用 Moq。
///
public class MultipartUploadSessionTests
{
private const string OwnerId = "user-001";
private const string Bucket = "bk";
/// 测试用 bucket 解析器,固定返回与预置会话一致的桶名 "bk"。
private static IBucketResolver BucketResolver()
{
var mock = new Mock();
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(Bucket);
return mock.Object;
}
///
/// 构造一组注入了 InMemory DbContext 的 handler 依赖。
/// 复用生产 AuditInterceptor 填审计字段——与真实运行环境一致,避免测试手工补字段失真。
///
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;
var db = new FileDbContext(options);
var s3 = new Mock();
return (db, s3, user);
}
///
/// 预置一条会话。审计字段由 AuditInterceptor 在 SaveChanges 时自动填充(与生产一致),
/// 故此处只需设业务字段。
///
private static MultipartUploadSessionEntity NewSession(string bucket, string key, string uploadId, string owner, string status = UploadSessionStatus.Active)
=> new()
{
BucketName = bucket,
ObjectKey = key,
UploadId = uploadId,
OwnerId = owner,
Status = status,
};
[Fact]
public async Task Init_首次_发起S3Init并落库会话()
{
var (db, s3, user) = CreateDeps(nameof(Init_首次_发起S3Init并落库会话));
s3.Setup(x => x.CreateMultipartUploadAsync("bk", "key", default)).ReturnsAsync("upload-1");
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
var result = await handler.Handle(new InitMultipartCommand("key", "hash-abc", TotalParts: 3), CancellationToken.None);
result.UploadId.Should().Be("upload-1");
result.Resume.Should().BeFalse();
// 落库一条 Active 会话
db.UploadSessions.Should().ContainSingle(s =>
s.UploadId == "upload-1" && s.OwnerId == OwnerId && s.Status == UploadSessionStatus.Active && s.TotalParts == 3);
s3.Verify(x => x.CreateMultipartUploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
}
[Fact]
public async Task Init_同对象已有Active会话_幂等复用不重复Init()
{
var (db, s3, user) = CreateDeps(nameof(Init_同对象已有Active会话_幂等复用不重复Init));
// 预置一条 Active 会话
db.UploadSessions.Add(NewSession("bk", "key", "upload-existing", OwnerId));
await db.SaveChangesAsync();
s3.Setup(x => x.CreateMultipartUploadAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync("upload-new"); // 不应被调用
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
var result = await handler.Handle(new InitMultipartCommand("key"), CancellationToken.None);
result.UploadId.Should().Be("upload-existing");
result.Resume.Should().BeTrue();
// 不应再次调用 S3 init,也不应新增会话记录
s3.Verify(x => x.CreateMultipartUploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
db.UploadSessions.Should().ContainSingle();
}
[Fact]
public async Task UploadPart_会话不存在_抛NotFound()
{
var (db, s3, user) = CreateDeps(nameof(UploadPart_会话不存在_抛NotFound));
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
var act = () => handler.Handle(new UploadPartCommand("key", "no-such", 1, new MemoryStream([1])), CancellationToken.None);
await act.Should().ThrowAsync();
s3.Verify(x => x.UploadPartAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task UploadPart_他人会话_抛NotFound防枚举()
{
var (db, s3, user) = CreateDeps(nameof(UploadPart_他人会话_抛NotFound防枚举));
db.UploadSessions.Add(NewSession("bk", "key", "upload-other", "another-user"));
await db.SaveChangesAsync();
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
var act = () => handler.Handle(new UploadPartCommand("key", "upload-other", 1, new MemoryStream([1])), CancellationToken.None);
// 他人会话也报 NotFound,不泄露存在性
await act.Should().ThrowAsync();
}
[Fact]
public async Task UploadPart_会话已Completed_抛Conflict()
{
var (db, s3, user) = CreateDeps(nameof(UploadPart_会话已Completed_抛Conflict));
db.UploadSessions.Add(NewSession("bk", "key", "upload-done", OwnerId, UploadSessionStatus.Completed));
await db.SaveChangesAsync();
var handler = new UploadPartCommandHandler(s3.Object, db, user.Object);
var act = () => handler.Handle(new UploadPartCommand("key", "upload-done", 1, new MemoryStream([1])), CancellationToken.None);
await act.Should().ThrowAsync();
}
[Fact]
public async Task Complete_成功后会话标记Completed()
{
var (db, s3, user) = CreateDeps(nameof(Complete_成功后会话标记Completed));
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
await db.SaveChangesAsync();
var handler = new CompleteMultipartCommandHandler(s3.Object, db, user.Object, BucketResolver());
var parts = new List { new(1, "etag-1") };
var result = await handler.Handle(new CompleteMultipartCommand("key", "upload-1", parts), CancellationToken.None);
result.Location.Should().Be("bk/key");
s3.Verify(x => x.CompleteMultipartUploadAsync("bk", "key", "upload-1", It.IsAny>(), It.IsAny()), Times.Once);
var session = await db.UploadSessions.SingleAsync();
session.Status.Should().Be(UploadSessionStatus.Completed);
}
[Fact]
public async Task Abort_成功后会话标记Aborted()
{
var (db, s3, user) = CreateDeps(nameof(Abort_成功后会话标记Aborted));
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
await db.SaveChangesAsync();
var handler = new AbortMultipartCommandHandler(s3.Object, db, user.Object);
await handler.Handle(new AbortMultipartCommand("key", "upload-1"), CancellationToken.None);
s3.Verify(x => x.AbortMultipartUploadAsync("bk", "key", "upload-1", It.IsAny()), Times.Once);
var session = await db.UploadSessions.SingleAsync();
session.Status.Should().Be(UploadSessionStatus.Aborted);
}
[Fact]
public async Task ListParts_返回S3已传分片_按序号升序()
{
var (db, s3, user) = CreateDeps(nameof(ListParts_返回S3已传分片_按序号升序));
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId));
await db.SaveChangesAsync();
// S3 返回乱序分片,验证 handler 内排序
s3.Setup(x => x.ListPartsAsync("bk", "key", "upload-1", It.IsAny()))
.ReturnsAsync(new ListPartsResult(
[
new PartInfo(3, "e3", 5),
new PartInfo(1, "e1", 5),
new PartInfo(2, "e2", 5),
]));
var handler = new ListPartsQueryHandler(s3.Object, db, user.Object);
var result = await handler.Handle(new ListPartsQuery("upload-1"), CancellationToken.None);
result.Parts.Should().HaveCount(3);
result.Parts.Select(p => p.PartNumber).Should().Equal(1, 2, 3);
result.Parts.Select(p => p.ETag).Should().Equal("e1", "e2", "e3");
}
[Fact]
public async Task ListParts_已结束会话_返回空列表不报错()
{
var (db, s3, user) = CreateDeps(nameof(ListParts_已结束会话_返回空列表不报错));
db.UploadSessions.Add(NewSession("bk", "key", "upload-1", OwnerId, UploadSessionStatus.Completed));
await db.SaveChangesAsync();
var handler = new ListPartsQueryHandler(s3.Object, db, user.Object);
var result = await handler.Handle(new ListPartsQuery("upload-1"), CancellationToken.None);
result.Parts.Should().BeEmpty();
s3.Verify(x => x.ListPartsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
}