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