- 删除文件时先删S3对象再删FileMeta记录,防止出现孤儿文件或残留对象 - 在DeleteFileCommandHandler中注入FileDbContext以支持数据库操作 - 更新文件删除逻辑:精确匹配bucket+key删除对应的FileMeta记录 - 添加DatabaseInitializer类统一处理启动期数据库初始化流程 - 将原有的数据库迁移、种子数据等逻辑抽取到独立的初始化服务中 - 新增HostObjectAccessors类简化ABP与ASP.NET Core管线的双向访问配置 - 添加平台模块废弃权限清理功能,移除OrderManagement分组的残留权限 - 实现完整的单元测试覆盖文件删除的各种场景和边界情况
163 lines
6.8 KiB
C#
163 lines
6.8 KiB
C#
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);
|
||
}
|
||
}
|