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; 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 MultipartUploadSession 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); } }