节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
5.14 KiB
Stardust
using System.Net.Http.Json;
using System.Text.Json;
using NewLife.Cube;
using Xunit;
using Xunit.Abstractions;

namespace Stardust.WebApiTests.Fixtures;

/// <summary>
/// 集成测试的 API 客户端:负责请求发送、URL 规范化与统一信封校验,
/// 把每个请求的 URL / 状态码 / 响应体写进 xUnit 测试输出,断言失败时可快速定位。
/// </summary>
/// <remarks>
/// 只做「信封级」断言(HTTP 成功 + 可反序列化 + Code==0),业务断言留给各个测试方法,
/// 避免把「断言什么」藏进基类导致失败信息只剩一句 InvalidOperationException。
/// </remarks>
public sealed class ApiClient
{
    /// <summary>与 HttpClient 的 ReadFromJsonAsync 默认行为一致(Web 约定:小驼峰 + 大小写不敏感)。</summary>
    private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);

    private readonly WebApiTestFactory _factory;
    private readonly ITestOutputHelper _output;

    public ApiClient(WebApiTestFactory factory, ITestOutputHelper output)
    {
        _factory = factory;
        _output = output;
    }

    #region URL 规范化
    /// <summary>
    /// 区域控制器走 Cube 的区域路由模板 <c>api/{area}/{controller}/{action}</c>,需要 /api 前缀;
    /// 少数控制器用特性路由自成一派,这里显式登记,新增特性路由控制器时只需加一行。
    /// </summary>
    private static readonly (String EndpointPrefix, String UrlPrefix)[] _routeRules =
    [
        // [Route("[controller]/[action]")]
        ("Auth/", "/"),
        // [Route("[area]/[controller]/[action]")]
        ("Deployment/PipelineWebhook/", "/"),
        // Lov 控制器位于 Admin 区域,测试中简写为 Lov/...
        ("Lov/", "/api/Admin/"),
    ];

    /// <summary>把测试里书写的端点规范化为可请求的绝对 URL。</summary>
    public static String NormalizeUrl(String endpoint)
    {
        var e = endpoint.TrimStart('/');
        if (e.StartsWith("api/", StringComparison.OrdinalIgnoreCase)) return "/" + e;

        foreach (var (endpointPrefix, urlPrefix) in _routeRules)
        {
            if (e.StartsWith(endpointPrefix, StringComparison.OrdinalIgnoreCase)) return urlPrefix + e;
        }

        return "/api/" + e;
    }
    #endregion

    /// <summary>GET 请求。auth=true 用带 Bearer 的认证客户端,否则匿名客户端。</summary>
    public async Task<HttpResponseMessage> GetAsync(String endpoint, Boolean auth = true) =>
        await SendAsync(client => client.GetAsync(NormalizeUrl(endpoint)), "GET", endpoint, auth);

    /// <summary>POST JSON 请求。auth 同上。</summary>
    public async Task<HttpResponseMessage> PostAsJsonAsync<T>(String endpoint, T payload, Boolean auth = true) =>
        await SendAsync(client => client.PostAsJsonAsync(NormalizeUrl(endpoint), payload, _json), "POST", endpoint, auth);

    /// <summary>GET 并校验统一信封,返回强类型响应供测试做业务断言。</summary>
    public async Task<ApiResponse<T>> GetApiAsync<T>(String endpoint, Boolean auth = true)
    {
        var resp = await GetAsync(endpoint, auth);
        return await ToApiResponseAsync<T>(resp, endpoint);
    }

    /// <summary>POST JSON 并校验统一信封,返回强类型响应供测试做业务断言。</summary>
    public async Task<ApiResponse<T>> PostApiAsync<T>(String endpoint, Object? payload, Boolean auth = true)
    {
        var resp = await PostAsJsonAsync(endpoint, payload, auth);
        return await ToApiResponseAsync<T>(resp, endpoint);
    }

    /// <summary>data 结构不固定的端点(页面、字段列表等):用 JsonElement 承载。</summary>
    public Task<ApiResponse<JsonElement>> GetJsonAsync(String endpoint, Boolean auth = true) =>
        GetApiAsync<JsonElement>(endpoint, auth);

    private async Task<HttpResponseMessage> SendAsync(Func<HttpClient, Task<HttpResponseMessage>> send, String method, String endpoint, Boolean auth)
    {
        var url = NormalizeUrl(endpoint);
        var client = auth ? await _factory.CreateAuthenticatedClient() : _factory.CreateClient();

        _output.WriteLine($"→ {method} {url} (auth={auth})");
        var resp = await send(client);
        _output.WriteLine($"← {method} {url} -> {(Int32)resp.StatusCode} {resp.StatusCode}");

        return resp;
    }

    /// <summary>校验统一信封:HTTP 成功 + 可反序列化为 ApiResponse&lt;T&gt; + Code==0。</summary>
    private async Task<ApiResponse<T>> ToApiResponseAsync<T>(HttpResponseMessage resp, String endpoint)
    {
        var url = NormalizeUrl(endpoint);
        var raw = await resp.Content.ReadAsStringAsync();

        Assert.True(resp.IsSuccessStatusCode, $"[{url}] HTTP 状态码 {(Int32)resp.StatusCode} 非成功:{raw}");

        ApiResponse<T>? api = null;
        try
        {
            api = JsonSerializer.Deserialize<ApiResponse<T>>(raw, _json);
        }
        catch (JsonException ex)
        {
            Assert.Fail($"[{url}] 响应格式不符合 ApiResponse<{typeof(T).Name}>:{ex.Message}{Environment.NewLine}{raw}");
        }

        Assert.NotNull(api);
        Assert.Equal(0, api!.Code);

        return api;
    }
}