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); } }