build-flow.api/tests/BuildFlow.File.Tests/MultipartUploadSessionTests.cs
xiangning 3b13099378 refactor(domain): 重构领域实体结构并更新枚举命名
- 将通用 Entities 命名空间拆分为具体业务实体子目录
- 更新所有实体类型引用为具体的 Entity 后缀类型
- 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀
- 重构 AiModel 相关配置和映射逻辑适配新的实体结构
- 更新测试代码中的实体类型引用和枚举使用
- 调整应用层处理器中的实体创建和查询逻辑
- 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
2026-07-19 14:02:32 +08:00

222 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.FileMeta;
using File.Domain.Entities.FileSetting;
using File.Domain.Entities.Folder;
using File.Domain.Entities.MultipartUploadSession;
using File.Domain.Entities.ShareLink;
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;
/// <summary>
/// 断点续传会话核心逻辑测试。建立项目首个用 EF InMemory + 真实 FileDbContext 的测试模式:
/// 每个测试用独立库名隔离,S3 用 Moq,CurrentUserContext 用 Moq。
/// </summary>
public class MultipartUploadSessionTests
{
private const string OwnerId = "user-001";
private const string Bucket = "bk";
/// <summary>测试用 bucket 解析器,固定返回与预置会话一致的桶名 "bk"。</summary>
private static IBucketResolver BucketResolver()
{
var mock = new Mock<IBucketResolver>();
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Bucket);
return mock.Object;
}
/// <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 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<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, 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<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("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("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("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, BucketResolver());
var parts = new List<CompletedPartDto> { 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<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("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);
}
}