using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Infrastructure;
using File.Application.Common;
using File.Application.Features.Files.Commands;
using File.Application.Features.Files.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;
///
/// 删除文件命令处理器测试。核心回归:删除必须同时清理 S3 对象和 FileMeta 记录,
/// 否则文件夹视图(读 DB)仍显示已失效文件,或 S3 残留孤儿对象。
///
public class DeleteFileCommandHandlerTests
{
private const string Bucket = "build-flow";
private const string Key = "multipart/abc123";
/// 测试用 bucket 解析器,固定返回与 一致的桶名。
private static IBucketResolver BucketResolver()
{
var mock = new Mock();
mock.Setup(r => r.GetDefaultBucketAsync(It.IsAny())).ReturnsAsync(Bucket);
return mock.Object;
}
/// 构造测试依赖:InMemory DB(挂审计拦截器自动填充非空字段)+ Mock S3 + Mock Mediator。
private static (FileDbContext db, Mock s3, Mock mediator) CreateDeps(string dbName)
{
var user = new Mock();
user.SetupGet(u => u.UserId).Returns("user-001");
user.SetupGet(u => u.IPAddress).Returns("127.0.0.1");
var s3 = new Mock();
var mediator = new Mock();
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(dbName)
.AddInterceptors(new AuditInterceptor(user.Object))
.Options;
return (new FileDbContext(options), s3, mediator);
}
/// 构造一条 FileMeta 记录。
private static FileMeta NewMeta(string bucket, string key, string owner = "user-001")
=> new()
{
Id = Guid.NewGuid(),
Name = key,
S3Key = key,
S3Bucket = bucket,
Size = 1024,
ContentType = "text/plain",
OwnerId = owner,
};
[Fact]
public async Task Delete_存在FileMeta_同时删S3和数据库记录()
{
// Arrange:DB 有一条匹配的 FileMeta
var (db, s3, mediator) = CreateDeps(nameof(Delete_存在FileMeta_同时删S3和数据库记录));
db.Files.Add(NewMeta(Bucket, Key));
await db.SaveChangesAsync();
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
// Assert:S3 对象已删
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny()), Times.Once);
// Assert:FileMeta 记录已删(核心回归点——之前漏删导致文件夹视图仍显示)
var remaining = await db.Files.ToListAsync();
remaining.Should().BeEmpty("删除后 FileMeta 记录必须同步清理");
}
[Fact]
public async Task Delete_无FileMeta记录_仍删S3对象_不报错()
{
// Arrange:DB 无匹配记录(如根目录直传未落库、或已被其他途径清理)
var (db, s3, mediator) = CreateDeps(nameof(Delete_无FileMeta记录_仍删S3对象_不报错));
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
// Assert:S3 仍尝试删除(幂等,删不存在的对象也返回成功)
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny()), Times.Once);
}
[Fact]
public async Task Delete_多条同bucket同key的FileMeta_全部清理()
{
// Arrange:极端场景——同 bucket+key 存在多条 FileMeta(历史数据或并发落库)
var (db, s3, mediator) = CreateDeps(nameof(Delete_多条同bucket同key的FileMeta_全部清理));
db.Files.Add(NewMeta(Bucket, Key, "user-001"));
db.Files.Add(NewMeta(Bucket, Key, "user-002"));
await db.SaveChangesAsync();
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
// Assert:两条记录都被清理
var remaining = await db.Files.Where(f => f.S3Bucket == Bucket && f.S3Key == Key).ToListAsync();
remaining.Should().BeEmpty("同 bucket+key 的所有 FileMeta 都应被清理");
}
[Fact]
public async Task Delete_不影响其他文件的FileMeta()
{
// Arrange:删 A 文件,B 文件应不受影响
var (db, s3, mediator) = CreateDeps(nameof(Delete_不影响其他文件的FileMeta));
db.Files.Add(NewMeta(Bucket, "multipart/aaa"));
db.Files.Add(NewMeta(Bucket, "multipart/bbb"));
await db.SaveChangesAsync();
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act:只删 aaa
await handler.Handle(new DeleteFileCommand("multipart/aaa"), CancellationToken.None);
// Assert:aaa 没了,bbb 还在
var remaining = await db.Files.ToListAsync();
remaining.Should().HaveCount(1);
remaining[0].S3Key.Should().Be("multipart/bbb");
}
[Fact]
public async Task Delete_S3删除失败_抛异常不删数据库()
{
// Arrange:S3 删除抛异常(网络故障/权限不足等)
var (db, s3, mediator) = CreateDeps(nameof(Delete_S3删除失败_抛异常不删数据库));
db.Files.Add(NewMeta(Bucket, Key));
await db.SaveChangesAsync();
s3.Setup(x => x.DeleteFileAsync(Bucket, Key, It.IsAny()))
.ThrowsAsync(new Exception("S3 连接失败"));
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act
var act = () => handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
// Assert:异常向上抛
await act.Should().ThrowAsync().WithMessage("S3 连接失败");
// Assert:FileMeta 仍在(S3 删失败时不删库,避免库删了但对象还在的孤儿)
var remaining = await db.Files.ToListAsync();
remaining.Should().HaveCount(1, "S3 删除失败时不应清理数据库记录");
}
[Fact]
public async Task Delete_发布FileDeletedEvent()
{
// Arrange
var (db, s3, mediator) = CreateDeps(nameof(Delete_发布FileDeletedEvent));
var handler = new DeleteFileCommandHandler(s3.Object, db, BucketResolver(), mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Key), CancellationToken.None);
// Assert:事件已发布(供其他模块响应,如清理缓存)
mediator.Verify(
x => x.Publish(It.IsAny(), It.IsAny()),
Times.Once);
}
}