207 lines
9.4 KiB
C#
207 lines
9.4 KiB
C#
using BuildFlow.Shared.Domain.Common;
|
||
using BuildFlow.Shared.Domain.Exceptions;
|
||
using BuildFlow.Shared.Infrastructure;
|
||
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;
|
||
|
||
/// <summary>
|
||
/// 断点续传会话核心逻辑测试。建立项目首个用 EF InMemory + 真实 FileDbContext 的测试模式:
|
||
/// 每个测试用独立库名隔离,S3 用 Moq,CurrentUserContext 用 Moq。
|
||
/// </summary>
|
||
public class MultipartUploadSessionTests
|
||
{
|
||
private const string OwnerId = "user-001";
|
||
|
||
/// <summary>
|
||
/// 构造一组注入了 InMemory DbContext 的 handler 依赖。
|
||
/// 复用生产 AuditInterceptor 填审计字段——与真实运行环境一致,避免测试手工补字段失真。
|
||
/// </summary>
|
||
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;
|
||
var db = new FileDbContext(options);
|
||
|
||
var s3 = new Mock<IS3StorageService>();
|
||
return (db, s3, user);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预置一条会话。审计字段由 AuditInterceptor 在 SaveChanges 时自动填充(与生产一致),
|
||
/// 故此处只需设业务字段。
|
||
/// </summary>
|
||
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);
|
||
|
||
var result = await handler.Handle(new InitMultipartCommand("bk", "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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync("upload-new"); // 不应被调用
|
||
var handler = new InitMultipartCommandHandler(s3.Object, db, user.Object);
|
||
|
||
var result = await handler.Handle(new InitMultipartCommand("bk", "key"), CancellationToken.None);
|
||
|
||
result.UploadId.Should().Be("upload-existing");
|
||
result.Resume.Should().BeTrue();
|
||
// 不应再次调用 S3 init,也不应新增会话记录
|
||
s3.Verify(x => x.CreateMultipartUploadAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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("bk", "key", "no-such", 1, new MemoryStream([1])), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<NotFoundException>();
|
||
s3.Verify(x => x.UploadPartAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<Stream>(), It.IsAny<CancellationToken>()), 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("bk", "key", "upload-other", 1, new MemoryStream([1])), CancellationToken.None);
|
||
|
||
// 他人会话也报 NotFound,不泄露存在性
|
||
await act.Should().ThrowAsync<NotFoundException>();
|
||
}
|
||
|
||
[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("bk", "key", "upload-done", 1, new MemoryStream([1])), CancellationToken.None);
|
||
|
||
await act.Should().ThrowAsync<ConflictException>();
|
||
}
|
||
|
||
[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);
|
||
var parts = new List<CompletedPartDto> { new(1, "etag-1") };
|
||
|
||
var result = await handler.Handle(new CompleteMultipartCommand("bk", "key", "upload-1", parts), CancellationToken.None);
|
||
|
||
result.Location.Should().Be("bk/key");
|
||
s3.Verify(x => x.CompleteMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<List<CompletedPart>>(), It.IsAny<CancellationToken>()), 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("bk", "key", "upload-1"), CancellationToken.None);
|
||
|
||
s3.Verify(x => x.AbortMultipartUploadAsync("bk", "key", "upload-1", It.IsAny<CancellationToken>()), 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<CancellationToken>()))
|
||
.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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||
}
|
||
}
|