using BuildFlow.Shared.Domain.Common; using BuildFlow.Shared.Domain.Exceptions; using BuildFlow.Shared.Infrastructure; using File.Application.Features.Shares.Handlers; using File.Application.Features.Shares.Queries; using File.Domain.Entities; using File.Domain.Exceptions; using File.Infrastructure.Persistence; using File.Infrastructure.Storage; using FluentAssertions; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace BuildFlow.File.Tests; /// /// [SOP-FILE-02] 分享下载端到端测试。 /// 覆盖:正常下载/过期/次数耗尽/密码校验。 /// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/GeOwweCmfi1yMLk62iIcOKLInOE /// public class ShareDownloadTests { 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 options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"share-{dbName}-{Guid.NewGuid():N}") .AddInterceptors(new AuditInterceptor(user.Object)) .Options; var db = new FileDbContext(options); var s3 = new Mock(); s3.Setup(x => x.GeneratePresignedUrlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync("https://presigned.example.com/file"); return (db, s3, new Mock()); } private static async Task<(FileDbContext db, DownloadShareQueryHandler handler)> SetupWithShareAsync( string name, ShareLink share) { var (db, s3, mediator) = CreateDeps(name); share.ResourceType = "file"; // 必填(folder/file) db.ShareLinks.Add(share); db.Files.Add(new FileMeta { Id = share.ResourceId, Name = "doc.pdf", S3Key = "k", S3Bucket = "b", ContentType = "application/pdf", OwnerId = "user-001" }); await db.SaveChangesAsync(); var handler = new DownloadShareQueryHandler(db, s3.Object, mediator.Object, NullLogger.Instance); return (db, handler); } private static string UniqueToken(string seed) => (seed + "padding1234567890123456789012")[..32]; [Fact] public async Task Download_ValidShare_ReturnsPresignedUrl() { var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("valid") }; var (_, handler) = await SetupWithShareAsync("valid", share); var result = await handler.Handle( new DownloadShareQuery(share.Token, null), CancellationToken.None); result.PresignedUrl.Should().StartWith("https://presigned"); result.FileName.Should().Be("doc.pdf"); } [Fact] public async Task Download_Expired_ThrowsShareExpired() { var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("expired"), ExpiresAt = DateTime.UtcNow.AddMinutes(-10) }; var (_, handler) = await SetupWithShareAsync("expired", share); var act = async () => await handler.Handle( new DownloadShareQuery(share.Token, null), CancellationToken.None); await act.Should().ThrowAsync("过期分享应被拒绝"); } [Fact] public async Task Download_Exhausted_ThrowsShareExhausted() { var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("exhausted"), MaxDownloads = 3, DownloadCount = 3 }; var (_, handler) = await SetupWithShareAsync("exhausted", share); var act = async () => await handler.Handle( new DownloadShareQuery(share.Token, null), CancellationToken.None); await act.Should().ThrowAsync("次数耗尽应被拒绝"); } [Fact] public async Task Download_ProtectedNoPassword_ThrowsSharePassword() { var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("pwd-missing"), Password = BCrypt.Net.BCrypt.HashPassword("secret123") }; var (_, handler) = await SetupWithShareAsync("pwd-missing", share); var act = async () => await handler.Handle( new DownloadShareQuery(share.Token, null), CancellationToken.None); await act.Should().ThrowAsync("无密码访问受保护分享应被拒绝"); } [Fact] public async Task Download_ProtectedWrongPassword_ThrowsSharePassword() { var share = new ShareLink { ResourceId = Guid.NewGuid(), Token = UniqueToken("pwd-wrong"), Password = BCrypt.Net.BCrypt.HashPassword("secret123") }; var (_, handler) = await SetupWithShareAsync("pwd-wrong", share); var act = async () => await handler.Handle( new DownloadShareQuery(share.Token, "wrongpassword"), CancellationToken.None); await act.Should().ThrowAsync("密码错误应被拒绝"); } [Fact] public async Task Download_NonExistentToken_ThrowsNotFound() { var (db, s3, mediator) = CreateDeps("not-found"); var handler = new DownloadShareQueryHandler(db, s3.Object, mediator.Object, NullLogger.Instance); var act = async () => await handler.Handle( new DownloadShareQuery(UniqueToken("nope"), null), CancellationToken.None); await act.Should().ThrowAsync("不存在的 token 应返回 NotFound"); } }