using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using Dpz.Core.Entity.Base.Image;
using Dpz.Core.Service.ObjectStorage;
using Dpz.Core.Service.ObjectStorage.Services;
using Dpz.Core.Shard.Service;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NUnit.Framework;

namespace Dpz.Core.Server.ObjectStorage.Test;

/// <summary>
/// 腾讯云 COS 与数据万象配置验证 S3 对象存储集成测试
/// </summary>
[TestFixture]
public class S3ObjectStorageServiceIntegrationTest
{
    private readonly IConfiguration _configuration = new ConfigurationBuilder()
        .AddJsonFile("appsettings.Test.json")
        .Build();

    /// <summary>
    /// 验证文本文件上传、下载、目录列表、元信息读取、另存和删除的完整链路
    /// </summary>
    [Test]
    public async Task S3_UploadDownloadListMetadataSaveAndDelete_ShouldComplete()
    {
        AssertS3Configured();
        var cdnHost = _configuration["S3:CdnHost"]!;
        var objectStorageService = CreateObjectStorageService();

        var testRoot = CreateTestRoot();
        var key = $"{testRoot}/hello.txt";
        var savePath = Path.Combine(
            TestContext.CurrentContext.WorkDirectory,
            $"{Guid.NewGuid():N}.txt"
        );
        try
        {
            var bytes = Encoding.UTF8.GetBytes("COS/S3 integration test, 中文内容");
            var uploadResult = await objectStorageService.UploadAsync(
                bytes,
                ["Test", "S3", testRoot.Split('/')[2]],
                "hello.txt"
            );

            Assert.That(uploadResult.Url, Does.StartWith(cdnHost));

            await using (var stream = await objectStorageService.DownloadAsync(key))
            {
                using var reader = new StreamReader(stream, Encoding.UTF8);
                Assert.That(
                    await reader.ReadToEndAsync(),
                    Is.EqualTo(Encoding.UTF8.GetString(bytes))
                );
            }

            var information = await objectStorageService.GetFileInformationAsync(key);
            Assert.Multiple(() =>
            {
                Assert.That(information.Length, Is.EqualTo(bytes.Length));
                Assert.That(information.Md5, Is.Not.Null.And.Not.Empty);
            });

            var folderList = await objectStorageService.GetFolderListAsync(testRoot);
            Assert.That(folderList.Any(x => x.Name == "hello.txt"), Is.True);

            await objectStorageService.SaveAsAsync(key, savePath);
            Assert.That(
                await File.ReadAllTextAsync(savePath),
                Is.EqualTo(Encoding.UTF8.GetString(bytes))
            );
        }
        finally
        {
            if (File.Exists(savePath))
            {
                File.Delete(savePath);
            }

            await objectStorageService.DeleteAsync(key);
        }
    }

    /// <summary>
    /// 验证 PNG 上传后能通过腾讯云数据万象适配为 ImageMetadata
    /// </summary>
    [Test]
    public async Task S3_UploadPng_ShouldReturnCloudInfiniteImageMetadata()
    {
        AssertS3Configured();
        var provider = CreateServiceProvider();
        var objectStorageService = provider.GetRequiredService<IObjectStorageOperation>();
        var cloudInfiniteService = provider.GetRequiredService<ICloudInfiniteService>();

        var testRoot = CreateTestRoot();
        var key = $"{testRoot}/pixel.png";
        try
        {
            var bytes = Convert.FromBase64String(
                "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
            );
            var result = await objectStorageService.UploadAsync(
                bytes,
                ["Test", "S3", testRoot.Split('/')[2]],
                "pixel.png"
            );

            Assert.That(result, Is.TypeOf<ImageMetadata>());
            var metadata = (ImageMetadata)result;
            var directMetadata = await cloudInfiniteService.GetImageMetadataAsync(key, result.Url);
            Assert.Multiple(() =>
            {
                Assert.That(metadata.Width, Is.EqualTo(1));
                Assert.That(metadata.Height, Is.EqualTo(1));
                Assert.That(metadata.Format.Kind, Is.EqualTo(ImageFormatKind.Png));
                Assert.That(metadata.Size, Is.GreaterThan(0));
                Assert.That(directMetadata, Is.Not.Null);
                Assert.That(directMetadata!.Value.Width, Is.EqualTo(1));
                Assert.That(directMetadata.Value.Height, Is.EqualTo(1));
                Assert.That(directMetadata.Value.Format.Kind, Is.EqualTo(ImageFormatKind.Png));
                Assert.That(directMetadata.Value.Size, Is.GreaterThan(0));
            });
        }
        finally
        {
            await objectStorageService.DeleteAsync(key);
        }
    }

    /// <summary>
    /// 验证 SVG 上传后能从 COS 原图兜底解析为 ImageMetadata
    /// </summary>
    [Test]
    public async Task S3_UploadSvg_ShouldReturnCloudInfiniteSvgMetadata()
    {
        AssertS3Configured();
        var provider = CreateServiceProvider();
        var objectStorageService = provider.GetRequiredService<IObjectStorageOperation>();
        var cloudInfiniteService = provider.GetRequiredService<ICloudInfiniteService>();

        var testRoot = CreateTestRoot();
        var key = $"{testRoot}/vector.svg";
        try
        {
            var bytes = Encoding.UTF8.GetBytes(
                """
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="32"></svg>
                """
            );
            var result = await objectStorageService.UploadAsync(
                bytes,
                ["Test", "S3", testRoot.Split('/')[2]],
                "vector.svg"
            );
            var metadata = await cloudInfiniteService.GetImageMetadataAsync(key, result.Url);

            Assert.That(metadata, Is.Not.Null);
            Assert.Multiple(() =>
            {
                Assert.That(metadata!.Value.Width, Is.EqualTo(24));
                Assert.That(metadata.Value.Height, Is.EqualTo(32));
                Assert.That(metadata.Value.Format.Kind, Is.EqualTo(ImageFormatKind.Svg));
                Assert.That(metadata.Value.Size, Is.GreaterThan(0));
            });
        }
        finally
        {
            await objectStorageService.DeleteAsync(key);
        }
    }

    /// <summary>
    /// 验证 CloudFile 上传会返回公开访问地址和文件 MD5
    /// </summary>
    [Test]
    public async Task S3_UploadFileAsync_ShouldReturnMd5Address()
    {
        AssertS3Configured();
        var cdnHost = _configuration["S3:CdnHost"]!;
        var objectStorageService = CreateObjectStorageService();

        var testRoot = CreateTestRoot();
        var key = $"{testRoot}/cloud-file.txt";
        try
        {
            await using var stream = new MemoryStream("CloudFile 上传测试"u8.ToArray());
            var result = await objectStorageService.UploadFileAsync(
                new CloudFile(stream, ["Test", "S3", testRoot.Split('/')[2], "cloud-file.txt"])
            );

            Assert.That(result, Is.Not.Null);
            Assert.Multiple(() =>
            {
                Assert.That(result!.Value.Url, Does.StartWith(cdnHost));
                Assert.That(result.Value.Md5, Is.Not.Empty);
            });
        }
        finally
        {
            await objectStorageService.DeleteAsync(key);
        }
    }

    /// <summary>
    /// 验证空目录 marker 会被删除、包含文件的目录不会删除
    /// </summary>
    [Test]
    public async Task S3_CleanEmptyDirectories_ShouldDeleteEmptyMarkersOnly()
    {
        AssertS3Configured();
        IServiceCollection services = new ServiceCollection();
        services.AddSingleton(_configuration);
        services.AddS3ObjectStorage();
        services.AddLogging(configure => configure.AddConsole());
        var provider = services.BuildServiceProvider();
        var objectStorageService = provider.GetRequiredService<IObjectStorageOperation>();
        var amazonS3 = provider.GetRequiredService<IAmazonS3>();
        var bucket = _configuration["S3:Bucket"]!;

        var testRoot = CreateTestRoot();
        var emptyMarkerKey = $"{testRoot}/empty-dir/";
        var occupiedMarkerKey = $"{testRoot}/keep/";
        var fileKey = $"{testRoot}/keep/hello.txt";
        var zeroByteFileKey = $"{testRoot}/empty-file.txt";
        try
        {
            await amazonS3.PutObjectAsync(
                new PutObjectRequest
                {
                    BucketName = bucket,
                    Key = emptyMarkerKey,
                    InputStream = new MemoryStream([]),
                }
            );
            await amazonS3.PutObjectAsync(
                new PutObjectRequest
                {
                    BucketName = bucket,
                    Key = occupiedMarkerKey,
                    InputStream = new MemoryStream([]),
                }
            );
            await amazonS3.PutObjectAsync(
                new PutObjectRequest
                {
                    BucketName = bucket,
                    Key = fileKey,
                    InputStream = new MemoryStream("keep"u8.ToArray()),
                }
            );
            await amazonS3.PutObjectAsync(
                new PutObjectRequest
                {
                    BucketName = bucket,
                    Key = zeroByteFileKey,
                    InputStream = new MemoryStream([]),
                }
            );

            var deletedCount = await objectStorageService.CleanEmptyDirectoriesAsync(testRoot);

            Assert.That(deletedCount, Is.EqualTo(1));
            Assert.That(await ObjectExistsAsync(amazonS3, bucket, emptyMarkerKey), Is.False);
            Assert.That(await ObjectExistsAsync(amazonS3, bucket, occupiedMarkerKey), Is.True);
            Assert.That(await ObjectExistsAsync(amazonS3, bucket, fileKey), Is.True);
            Assert.That(await ObjectExistsAsync(amazonS3, bucket, zeroByteFileKey), Is.True);
        }
        finally
        {
            await DeleteObjectIfExistsAsync(amazonS3, bucket, emptyMarkerKey);
            await DeleteObjectIfExistsAsync(amazonS3, bucket, occupiedMarkerKey);
            await DeleteObjectIfExistsAsync(amazonS3, bucket, fileKey);
            await DeleteObjectIfExistsAsync(amazonS3, bucket, zeroByteFileKey);
        }
    }

    /// <summary>
    /// 验证 S3 测试所需配置完整,不完整时跳过真实 COS 集成测试
    /// </summary>
    private void AssertS3Configured()
    {
        var requiredKeys = new[] { "Endpoint", "SecretId", "SecretKey", "Bucket", "CdnHost" };
        foreach (var key in requiredKeys)
        {
            var value = _configuration[$"S3:{key}"];
            if (string.IsNullOrWhiteSpace(value) || value.StartsWith('<'))
            {
                Assert.Ignore($"appsettings.Test.json 缺少可用的 S3:{key} 配置");
            }
        }
    }

    /// <summary>
    /// 创建只使用 S3/COS 注册的对象存储服务,避免依赖默认又拍云注册
    /// </summary>
    private IObjectStorageOperation CreateObjectStorageService()
    {
        return CreateServiceProvider().GetRequiredService<IObjectStorageOperation>();
    }

    private ServiceProvider CreateServiceProvider()
    {
        IServiceCollection services = new ServiceCollection();
        services.AddSingleton(_configuration);
        services.AddS3ObjectStorage();
        services.AddLogging(configure => configure.AddConsole());
        return services.BuildServiceProvider();
    }

    /// <summary>
    /// 创建每次测试独立的远程目录,方便 finally 清理测试文件
    /// </summary>
    private static string CreateTestRoot() => $"Test/S3/{Guid.NewGuid():N}";

    /// <summary>
    /// 判断对象是否存在于 COS
    /// </summary>
    private static async Task<bool> ObjectExistsAsync(IAmazonS3 amazonS3, string bucket, string key)
    {
        var response = await amazonS3.ListObjectsV2Async(
            new ListObjectsV2Request { BucketName = bucket, Prefix = key }
        );
        return response.S3Objects?.Any(x => x.Key == key) == true;
    }

    /// <summary>
    /// 删除测试对象,忽略不存在的对象
    /// </summary>
    private static async Task DeleteObjectIfExistsAsync(
        IAmazonS3 amazonS3,
        string bucket,
        string key
    )
    {
        await amazonS3.DeleteObjectAsync(
            new DeleteObjectRequest { BucketName = bucket, Key = key }
        );
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个针对 S3 对象存储服务(主要是腾讯云 COS)的 NUnit 集成测试类。代码验证了对象存储服务的各项核心功能是否正常工作。

整体结构

类定义

[TestFixture]
public class S3ObjectStorageServiceIntegrationTest
  • 使用 NUnit 测试框架
  • appsettings.Test.json 加载配置
  • 包含 6 个测试方法和 5 个辅助方法

核心测试方法

1. S3_UploadDownloadListMetadataSaveAndDelete_ShouldComplete

验证完整的文件操作链路:

  • 上传文本文件
  • 下载并验证内容
  • ✅ 获取文件元信息(大小、MD5)
  • ✅ 列出目录内容
  • 另存为本地文件
  • 删除远程文件

2. S3_UploadPng_ShouldReturnCloudInfiniteImageMetadata

验证图片处理功能:

  • 上传 1x1 像素的 PNG 图片(Base64 编码)
  • 验证返回 ImageMetadata(宽、高、格式、大小)
  • 通过 腾讯云数据万象(Cloud Infinite)服务直接获取元数据并验证

3. S3_UploadSvg_ShouldReturnCloudInfiniteSvgMetadata

验证 SVG 矢量图处理:

  • 上传包含 width="24" height="32" 的 SVG
  • 验证能正确解析 SVG 的宽高信息

4. S3_UploadFileAsync_ShouldReturnMd5Address

验证 CloudFile 上传接口:

  • 使用 MemoryStream 上传
  • 验证返回公开访问 URL 和文件 MD5 哈希值

5. S3_CleanEmptyDirectories_ShouldDeleteEmptyMarkersOnly

验证空目录清理逻辑:

创建文件结构:
├── empty-dir/          (空目录标记)
├── keep/               (目录标记)
│   └── hello.txt       (文件)
└── empty-file.txt      (0字节文件)

执行清理后:
✅ empty-dir/          被删除
✅ keep/               保留(包含文件)
✅ empty-file.txt      保留(虽然0字节但不是目录标记)

辅助方法

AssertS3Configured()

检查配置完整性:

必需配置项:Endpoint, SecretId, SecretKey, Bucket, CdnHost
如果缺失或包含占位符(如 <your-xxx>),跳过测试

CreateTestRoot()

每次测试创建唯一目录:

格式:Test/S3/{GUID}
作用:隔离不同测试,方便清理

ObjectExistsAsync() / DeleteObjectIfExistsAsync()

直接使用 AWS S3 SDK 操作对象,用于验证和清理。


关键技术点

1. 依赖注入配置

services.AddS3ObjectStorage();  // 注册 S3 服务
services.AddLogging();           // 添加控制台日志

2. 多重断言

Assert.Multiple(() => {
    Assert.That(metadata.Width, Is.EqualTo(1));
    Assert.That(metadata.Height, Is.EqualTo(1));
    // ... 多个断言
});

所有断言会执行完毕后统一报告失败,不会在第一个失败时停止。

3. 资源清理

每个测试使用 try-finally 确保:

  • 临时文件被删除
  • 远程对象被清理
  • 避免测试污染

4. 集成测试特点

  • 使用真实的云服务(腾讯云 COS)
  • 需要有效的配置才能运行
  • 验证完整的业务流程而非单元逻辑

测试覆盖范围

功能测试方法
文件上传/下载✅ 测试 1, 4
元数据获取✅ 测试 1, 2, 3
目录列表✅ 测试 1
图片处理✅ 测试 2, 3
空目录清理✅ 测试 5
CDN URL 生成✅ 测试 1, 4

这是一个典型的云服务集成测试套件,用于确保对象存储抽象层能正确对接腾讯云 COS 服务。

评论加载中...