build-flow.api/tests/BuildFlow.File.Tests/DeleteFileCommandHandlerTests.cs
xiangning bdde5b9b8a refactor(file): 重构文件删除逻辑确保S3与数据库同步清理
- 删除文件时先删S3对象再删FileMeta记录,防止出现孤儿文件或残留对象
- 在DeleteFileCommandHandler中注入FileDbContext以支持数据库操作
- 更新文件删除逻辑:精确匹配bucket+key删除对应的FileMeta记录
- 添加DatabaseInitializer类统一处理启动期数据库初始化流程
- 将原有的数据库迁移、种子数据等逻辑抽取到独立的初始化服务中
- 新增HostObjectAccessors类简化ABP与ASP.NET Core管线的双向访问配置
- 添加平台模块废弃权限清理功能,移除OrderManagement分组的残留权限
- 实现完整的单元测试覆盖文件删除的各种场景和边界情况
2026-07-03 09:42:05 +08:00

163 lines
6.8 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.Infrastructure;
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;
/// <summary>
/// 删除文件命令处理器测试。核心回归:删除必须同时清理 S3 对象和 FileMeta 记录,
/// 否则文件夹视图(读 DB)仍显示已失效文件,或 S3 残留孤儿对象。
/// </summary>
public class DeleteFileCommandHandlerTests
{
private const string Bucket = "build-flow";
private const string Key = "multipart/abc123";
/// <summary>构造测试依赖:InMemory DB(挂审计拦截器自动填充非空字段)+ Mock S3 + Mock Mediator。</summary>
private static (FileDbContext db, Mock<IS3StorageService> s3, Mock<MediatR.IMediator> mediator) CreateDeps(string dbName)
{
var user = new Mock<ICurrentUserContext>();
user.SetupGet(u => u.UserId).Returns("user-001");
user.SetupGet(u => u.IPAddress).Returns("127.0.0.1");
var s3 = new Mock<IS3StorageService>();
var mediator = new Mock<MediatR.IMediator>();
var options = new DbContextOptionsBuilder<FileDbContext>()
.UseInMemoryDatabase(dbName)
.AddInterceptors(new AuditInterceptor(user.Object))
.Options;
return (new FileDbContext(options), s3, mediator);
}
/// <summary>构造一条 FileMeta 记录。</summary>
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, mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
// Assert:S3 对象已删
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), 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, mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
// Assert:S3 仍尝试删除(幂等,删不存在的对象也返回成功)
s3.Verify(x => x.DeleteFileAsync(Bucket, Key, It.IsAny<CancellationToken>()), 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, mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Bucket, 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, mediator.Object);
// Act:只删 aaa
await handler.Handle(new DeleteFileCommand(Bucket, "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<CancellationToken>()))
.ThrowsAsync(new Exception("S3 连接失败"));
var handler = new DeleteFileCommandHandler(s3.Object, db, mediator.Object);
// Act
var act = () => handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
// Assert:异常向上抛
await act.Should().ThrowAsync<Exception>().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, mediator.Object);
// Act
await handler.Handle(new DeleteFileCommand(Bucket, Key), CancellationToken.None);
// Assert:事件已发布(供其他模块响应,如清理缓存)
mediator.Verify(
x => x.Publish(It.IsAny<global::File.Application.Events.FileDeletedEvent>(), It.IsAny<CancellationToken>()),
Times.Once);
}
}