解决MySql布尔型新旧版本兼容问题,采用枚举来表示布尔型的数据表。由正向工程赋值
大石头 authored at 2018-05-15 21:21:05
35.57 KiB
X
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using NewLife;
using NewLife.Configuration;
using NewLife.Remoting;
using NewLife.Serialization;
using Xunit;

namespace XUnitTest.Remoting;

/// <summary>ApiHttpClient 纯单元测试(不依赖 ApiServer)</summary>
public class ApiHttpClientUnitTests
{
    #region Mock Handler
    private class MockHttpMessageHandler : HttpMessageHandler
    {
        public Func<HttpRequestMessage, HttpResponseMessage>? Handler { get; set; }

        public HttpRequestMessage? LastRequest { get; private set; }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            LastRequest = request;

            var response = Handler?.Invoke(request) ?? new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"code\":0,\"data\":\"ok\"}")
            };

            return Task.FromResult(response);
        }
    }

    private class TestableApiHttpClient : ApiHttpClient
    {
        private readonly HttpMessageHandler _handler;
        private Boolean _clientCreated = false;

        public Boolean ClientCreated => _clientCreated;

        public TestableApiHttpClient(HttpMessageHandler handler, String url) : base(url)
        {
            _handler = handler;
        }

        protected override HttpClient CreateClient()
        {
            _clientCreated = true;

            var client = new HttpClient(_handler)
            {
                Timeout = TimeSpan.FromMilliseconds(Timeout)
            };

            var userAgent = DefaultUserAgent;
            if (!userAgent.IsNullOrEmpty()) client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);

            // 触发 OnCreateClient 事件需要通过基类调用
            return client;
        }
    }
    #endregion

    #region 构造与初始化测试
    [Fact(DisplayName = "默认构造函数测试")]
    public void DefaultConstructorTest()
    {
        var client = new ApiHttpClient();

        Assert.Equal(15_000, client.Timeout);
        Assert.False(client.UseProxy);
        Assert.Equal(LoadBalanceMode.Failover, client.LoadBalanceMode);
        Assert.NotNull(client.LoadBalancer);
        Assert.IsType<FailoverLoadBalancer>(client.LoadBalancer);
        Assert.Empty(client.Services);
    }

    [Fact(DisplayName = "URL构造函数测试")]
    public void UrlConstructorTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        Assert.Single(client.Services);
        Assert.Equal("http://127.0.0.1:8080/", client.Services[0].Address + "");
    }

    [Fact(DisplayName = "多URL构造函数测试")]
    public void MultiUrlConstructorTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080,http://127.0.0.1:8081,http://127.0.0.1:8082");

        Assert.Equal(3, client.Services.Count);
    }
    #endregion

    #region Token令牌测试
    [Fact(DisplayName = "Token属性测试")]
    public void TokenPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            Token = "my_token"
        };

        Assert.Equal("my_token", client.Token);
    }

    [Fact(DisplayName = "Authentication属性测试")]
    public void AuthenticationPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            Authentication = new AuthenticationHeaderValue("Bearer", "auth_token")
        };

        Assert.NotNull(client.Authentication);
        Assert.Equal("Bearer", client.Authentication.Scheme);
        Assert.Equal("auth_token", client.Authentication.Parameter);
    }

    [Fact(DisplayName = "服务节点独立Token解析测试")]
    public void ServiceTokenParsingTest()
    {
        var client = new ApiHttpClient();
        var svc = client.Add("test", "http://127.0.0.1:8080#token=node_secret");

        Assert.Equal("node_secret", svc.Token);
        Assert.Equal("http://127.0.0.1:8080/", svc.Address + "");
    }
    #endregion

    #region 负载均衡模式测试
    [Fact(DisplayName = "默认Failover模式测试")]
    public void DefaultFailoverModeTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        Assert.Equal(LoadBalanceMode.Failover, client.LoadBalanceMode);
        Assert.IsType<FailoverLoadBalancer>(client.LoadBalancer);
    }

    [Fact(DisplayName = "切换RoundRobin模式测试")]
    public void SwitchToRoundRobinTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        client.LoadBalanceMode = LoadBalanceMode.RoundRobin;

        Assert.Equal(LoadBalanceMode.RoundRobin, client.LoadBalanceMode);
        Assert.IsType<WeightedRoundRobinLoadBalancer>(client.LoadBalancer);
    }

    [Fact(DisplayName = "切换Race模式测试")]
    public void SwitchToRaceModeTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        client.LoadBalanceMode = LoadBalanceMode.Race;

        Assert.Equal(LoadBalanceMode.Race, client.LoadBalanceMode);
        Assert.IsType<RaceLoadBalancer>(client.LoadBalancer);
    }

    [Fact(DisplayName = "屏蔽时间设置测试")]
    public void ShieldingTimeSettingTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            ShieldingTime = 120
        };

        Assert.Equal(120, client.ShieldingTime);
        Assert.Equal(120, client.LoadBalancer.ShieldingTime);

        // 切换模式后屏蔽时间保留
        client.LoadBalanceMode = LoadBalanceMode.RoundRobin;
        Assert.Equal(120, client.LoadBalancer.ShieldingTime);
    }
    #endregion

    #region 服务地址测试
    [Fact(DisplayName = "SetServer替换地址测试")]
    public void SetServerReplaceTest()
    {
        var client = new ApiHttpClient();

        client.SetServer("http://127.0.0.1:8080");
        Assert.Single(client.Services);

        // 相同地址不替换
        client.SetServer("http://127.0.0.1:8080");
        Assert.Single(client.Services);

        // 不同地址替换
        client.SetServer("http://127.0.0.1:9090");
        Assert.Single(client.Services);
        Assert.Equal("http://127.0.0.1:9090/", client.Services[0].Address + "");
    }

    [Fact(DisplayName = "Add方法测试")]
    public void AddMethodTest()
    {
        var client = new ApiHttpClient();

        var svc = client.Add("primary", "http://127.0.0.1:8080");

        Assert.Single(client.Services);
        Assert.Equal("primary", svc.Name);
        Assert.Equal("http://127.0.0.1:8080/", svc.Address + "");
    }

    [Fact(DisplayName = "Add方法Uri重载测试")]
    public void AddUriOverloadTest()
    {
        var client = new ApiHttpClient();

        var svc = client.Add("service", new Uri("http://127.0.0.1:8080/api"));

        Assert.Equal("service", svc.Name);
        Assert.Equal("http://127.0.0.1:8080/api", svc.Address + "");
    }

    [Theory(DisplayName = "地址解析带名称和权重测试")]
    [InlineData("master=3*http://127.0.0.1:8080", "master", 3)]
    [InlineData("slave=7*http://127.0.0.1:8081", "slave", 7)]
    [InlineData("5*http://127.0.0.1:8082", "test", 5)]
    [InlineData("http://127.0.0.1:8083", "test", 1)]
    public void ParseAddressWithNameAndWeightTest(String address, String expectedName, Int32 expectedWeight)
    {
        var client = new ApiHttpClient();
        var svc = client.Add("test", address);

        if (address.Contains("="))
            Assert.Equal(expectedName, svc.Name);
        Assert.Equal(expectedWeight, svc.Weight);
    }

    [Fact(DisplayName = "AddServer批量添加测试")]
    public void AddServerBatchTest()
    {
        var client = new ApiHttpClient();

        var svcs = client.AddServer("api", "http://127.0.0.1:8080,http://127.0.0.1:8081", 5);

        Assert.Equal(2, svcs.Count);
        Assert.Equal("api", svcs[0].Name);
        Assert.Equal("api2", svcs[1].Name);
        Assert.Equal(5, svcs[0].Weight);
        Assert.Equal(5, svcs[1].Weight);
    }
    #endregion

    #region CodeName/DataName测试
    [Fact(DisplayName = "CodeName属性测试")]
    public void CodeNamePropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            CodeName = "status"
        };

        Assert.Equal("status", client.CodeName);
    }

    [Fact(DisplayName = "DataName属性测试")]
    public void DataNamePropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            DataName = "result"
        };

        Assert.Equal("result", client.DataName);
    }
    #endregion

    #region JsonHost测试
    [Fact(DisplayName = "JsonHost属性测试")]
    public void JsonHostPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        Assert.Null(client.JsonHost);

        var jsonHost = JsonHelper.Default;
        client.JsonHost = jsonHost;
        Assert.Equal(jsonHost, client.JsonHost);
    }
    #endregion

    #region 其它属性测试
    [Fact(DisplayName = "Timeout属性测试")]
    public void TimeoutPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            Timeout = 5_000
        };

        Assert.Equal(5_000, client.Timeout);
    }

    [Fact(DisplayName = "UseProxy属性测试")]
    public void UseProxyPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            UseProxy = true
        };

        Assert.True(client.UseProxy);
    }

    [Fact(DisplayName = "CertificateValidation属性测试")]
    public void CertificateValidationPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            CertificateValidation = true
        };

        Assert.True(client.CertificateValidation);
    }

    [Fact(DisplayName = "DefaultUserAgent属性测试")]
    public void DefaultUserAgentPropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080")
        {
            DefaultUserAgent = "TestApp/1.0"
        };

        Assert.Equal("TestApp/1.0", client.DefaultUserAgent);
    }

    [Fact(DisplayName = "SlowTrace属性测试")]
    public void SlowTracePropertyTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        Assert.Equal(5_000, client.SlowTrace);

        client.SlowTrace = 10_000;
        Assert.Equal(10_000, client.SlowTrace);
    }

    [Fact(DisplayName = "Source和Current初始为空测试")]
    public void SourceAndCurrentInitiallyNullTest()
    {
        var client = new ApiHttpClient("http://127.0.0.1:8080");

        Assert.Null(client.Source);
        Assert.Null(client.Current);
    }
    #endregion

    #region 事件测试
    [Fact(DisplayName = "OnRequest事件触发测试")]
    public async Task OnRequestEventTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"code\":0,\"data\":\"test\"}")
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var eventTriggered = false;
        HttpRequestMessage? capturedRequest = null;

        client.OnRequest += (sender, e) =>
        {
            eventTriggered = true;
            capturedRequest = e.Request;
        };

        await client.GetAsync<String>("api/test");

        Assert.True(eventTriggered);
        Assert.NotNull(capturedRequest);
    }

    [Fact(DisplayName = "OnCreateClient事件触发测试")]
    public async Task OnCreateClientEventTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"code\":0,\"data\":\"test\"}")
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");

        await client.GetAsync<String>("api/test");

        // 验证 HttpClient 被创建
        Assert.True(client.ClientCreated);
    }
    #endregion

    #region 请求方法测试
    [Fact(DisplayName = "GetAsync方法测试")]
    public async Task GetAsyncMethodTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                Assert.Equal(HttpMethod.Get, req.Method);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"get_result\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.GetAsync<String>("api/test");

        Assert.Equal("get_result", result);
    }

    [Fact(DisplayName = "PostAsync方法测试")]
    public async Task PostAsyncMethodTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                Assert.Equal(HttpMethod.Post, req.Method);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"post_result\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.PostAsync<String>("api/test", new { name = "test" });

        Assert.Equal("post_result", result);
    }

    [Fact(DisplayName = "PutAsync方法测试")]
    public async Task PutAsyncMethodTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                Assert.Equal(HttpMethod.Put, req.Method);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"put_result\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.PutAsync<String>("api/test", new { name = "test" });

        Assert.Equal("put_result", result);
    }

    [Fact(DisplayName = "DeleteAsync方法测试")]
    public async Task DeleteAsyncMethodTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                Assert.Equal(HttpMethod.Delete, req.Method);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"delete_result\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.DeleteAsync<String>("api/test");

        Assert.Equal("delete_result", result);
    }

    [Fact(DisplayName = "PatchAsync方法测试")]
    public async Task PatchAsyncMethodTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                Assert.Equal("PATCH", req.Method.Method);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"patch_result\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.PatchAsync<String>("api/test", new { name = "test" });

        Assert.Equal("patch_result", result);
    }
    #endregion

    #region 响应解析测试
    [Fact(DisplayName = "字典响应解析测试")]
    public async Task DictionaryResponseTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"code\":0,\"data\":{\"name\":\"test\",\"value\":123}}")
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        var result = await client.GetAsync<IDictionary<String, Object>>("api/test");

        Assert.NotNull(result);
        Assert.Equal("test", result["name"]);
        // Json 解析时数字可能是不同类型,使用 ToInt() 转换
        Assert.Equal(123, result["value"].ToInt());
    }

    [Fact(DisplayName = "Api异常响应测试")]
    public async Task ApiExceptionResponseTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"code\":500,\"message\":\"Internal Server Error\"}")
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");

        var ex = await Assert.ThrowsAsync<ApiException>(() => client.GetAsync<String>("api/test"));
        Assert.Equal(500, ex.Code);
        Assert.Equal("Internal Server Error", ex.Message);
    }

    [Fact(DisplayName = "Http错误响应测试")]
    public async Task HttpErrorResponseTest()
    {
        var handler = new MockHttpMessageHandler
        {
            Handler = req => new HttpResponseMessage(HttpStatusCode.NotFound)
            {
                Content = new StringContent("Not Found")
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");

        await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync<String>("api/test"));
    }
    #endregion

    #region 请求头测试
    [Fact(DisplayName = "Token请求头测试")]
    public async Task TokenRequestHeaderTest()
    {
        String? capturedAuth = null;
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                capturedAuth = req.Headers.Authorization?.ToString();
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"ok\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080")
        {
            Token = "my_token"
        };

        await client.GetAsync<String>("api/test");

        Assert.Equal("Bearer my_token", capturedAuth);
    }

    [Fact(DisplayName = "Authentication请求头测试")]
    public async Task AuthenticationRequestHeaderTest()
    {
        String? capturedAuth = null;
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                capturedAuth = req.Headers.Authorization?.ToString();
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"ok\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080")
        {
            Authentication = new AuthenticationHeaderValue("Basic", "dXNlcjpwYXNz")
        };

        await client.GetAsync<String>("api/test");

        Assert.Equal("Basic dXNlcjpwYXNz", capturedAuth);
    }

    [Fact(DisplayName = "Accept请求头测试_Json")]
    public async Task AcceptJsonRequestHeaderTest()
    {
        String? capturedAccept = null;
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                capturedAccept = req.Headers.Accept.ToString();
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"ok\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        await client.GetAsync<String>("api/test");

        Assert.Equal("application/json", capturedAccept);
    }

    [Fact(DisplayName = "Accept请求头测试_Binary")]
    public async Task AcceptBinaryRequestHeaderTest()
    {
        String? capturedAccept = null;
        var handler = new MockHttpMessageHandler
        {
            Handler = req =>
            {
                capturedAccept = req.Headers.Accept.ToString();
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new ByteArrayContent(new Byte[] { 1, 2, 3 })
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");
        await client.GetAsync<Byte[]>("api/test");

        Assert.Equal("application/octet-stream", capturedAccept);
    }
    #endregion

    #region 原始响应所有权
    /// <summary>带释放标记的内容,用于观察响应是否被销毁</summary>
    private class TrackingContent : StringContent
    {
        public Boolean Disposed { get; private set; }

        public TrackingContent(String content) : base(content) { }

        protected override void Dispose(Boolean disposing)
        {
            Disposed = true;
            base.Dispose(disposing);
        }
    }

    [Fact(DisplayName = "原始响应_所有权移交_内容不销毁")]
    public async Task RawResponseMessage_ContentReadable()
    {
        var content = new TrackingContent("raw-ok");
        var handler = new MockHttpMessageHandler
        {
            Handler = _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }
        };
        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");

        var msg = await client.GetAsync<HttpResponseMessage>("api/raw");
        Assert.NotNull(msg);
        Assert.Equal(HttpStatusCode.OK, msg.StatusCode);

        // 所有权移交调用方:内容必须保持可用(旧实现返回前已释放,这里 ObjectDisposedException 或标记变红)
        var body = await msg.Content.ReadAsStringAsync();
        Assert.Equal("raw-ok", body);
        Assert.False(content.Disposed);
    }

    [Fact(DisplayName = "普通结果_读取后响应仍被释放")]
    public async Task NormalResult_ResponseDisposed()
    {
        var content = new TrackingContent("{\"code\":0,\"data\":\"ok\"}");
        var handler = new MockHttpMessageHandler
        {
            Handler = _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }
        };
        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:8080");

        var rs = await client.GetAsync<String>("api/info");
        Assert.Equal("ok", rs);

        // 普通结果读取后立即释放,连接及时归还(条件分支不能把正常路径也放走)
        Assert.True(content.Disposed);
    }

    /// <summary>竞速专用:直接返回可控响应,跳过真实网络</summary>
    private class RaceTestableClient : ApiHttpClient
    {
        private Int32 _n;
        public TrackingContent? First;
        public TrackingContent? Second;

        public RaceTestableClient(String urls) : base(urls) => LoadBalanceMode = LoadBalanceMode.Race;

        protected override Task<HttpResponseMessage> SendOnServiceAsync(HttpRequestMessage request, ServiceEndpoint service, HttpClient client, Boolean onlyHeader, CancellationToken cancellationToken)
        {
            var content = new TrackingContent("race-ok");
            if (Interlocked.Increment(ref _n) == 1) First = content;
            else Second = content;

            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
        }
    }

    [Fact(DisplayName = "竞速调用_原始响应_内容不销毁")]
    public async Task Race_RawResponseMessage_ContentReadable()
    {
        var client = new RaceTestableClient("http://127.0.0.1:34567,http://127.0.0.1:34568");

        var msg = await client.InvokeRaceAsync<HttpResponseMessage>(HttpMethod.Get, "api/raw");
        Assert.NotNull(msg);

        // 竞速选中并返回的响应必须可读(旧实现 finally 里无条件释放)
        var body = await msg.Content.ReadAsStringAsync();
        Assert.Equal("race-ok", body);
    }
    #endregion

    #region 客户端生命周期
    /// <summary>带释放标记的处理器,用于观察 HttpClient 是否被释放</summary>
    private class TrackingHandler : HttpMessageHandler
    {
        public Boolean Disposed { get; private set; }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
            => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));

        protected override void Dispose(Boolean disposing)
        {
            Disposed = true;
            base.Dispose(disposing);
        }
    }

    [Fact(DisplayName = "负载均衡_轮换节点_旧客户端被释放")]
    public void LoadBalancer_Rotation_DisposesOldClient()
    {
        var handler = new TrackingHandler();
        var svc = new ServiceEndpoint("s1", "http://127.0.0.1:8080")
        {
            Client = new HttpClient(handler),
            CreateTime = DateTime.Now.AddMinutes(-11),
        };

        var lb = new FailoverLoadBalancer();
        lb.PutService([svc], svc, null);

        // 10 分钟到期轮换:重建之前必须释放旧客户端,避免连接池资源随节点丢弃而泄漏
        Assert.Null(svc.Client);
        Assert.True(handler.Disposed);
    }

    [Fact(DisplayName = "设置服务端地址_替换节点_旧客户端被释放")]
    public void SetServer_Replacement_DisposesOldClient()
    {
        var handler = new TrackingHandler();
        var client = new ApiHttpClient("http://127.0.0.1:8080");
        client.Services[0].Client = new HttpClient(handler);

        client.SetServer("http://127.0.0.1:9090");

        Assert.True(handler.Disposed);
        Assert.Single(client.Services);
        Assert.Equal("http://127.0.0.1:9090/", client.Services[0].Address + "");
    }

    /// <summary>带释放标记的 ApiHttpClient</summary>
    private class TrackingApiClient : ApiHttpClient
    {
        public Boolean DisposedFlag { get; private set; }

        protected override void Dispose(Boolean disposing)
        {
            DisposedFlag = true;
            base.Dispose(disposing);
        }
    }

    /// <summary>制造并发首访窗口的服务解析器:第一个工厂调用挂起,等第二个进入后再放行</summary>
    private class RacingResolver : ConfigServiceResolver
    {
        private readonly SemaphoreSlim _entered = new(0);
        private readonly SemaphoreSlim _release = new(0);
        private Int32 _count;

        public ConcurrentBag<TrackingApiClient> Created { get; } = new();

        public RacingResolver() : base((IConfigProvider)null!) => Servers = "http://127.0.0.1:8080/";

        protected override IApiClient CreateClient(String serviceName, String? tag)
        {
            var client = new TrackingApiClient();
            Created.Add(client);

            if (Interlocked.Increment(ref _count) == 1)
            {
                _entered.Release();
                _release.Wait(TimeSpan.FromSeconds(10));
            }
            else
            {
                _entered.Wait(TimeSpan.FromSeconds(10));
                _release.Release();
            }

            return client;
        }
    }

    [Fact(DisplayName = "服务解析器_并发首访_败者客户端被释放")]
    public async Task ServiceResolver_ConcurrentFirstAccess_DisposesLoser()
    {
        var resolver = new RacingResolver();

        var t1 = Task.Run(() => resolver.GetClientAsync("svc"));
        var t2 = Task.Run(() => resolver.GetClientAsync("svc"));
        var clients = await Task.WhenAll(t1, t2);

        // 两次调用必须拿到同一个客户端;工厂跑过两次,败者要就地释放而不是静默丢弃
        Assert.Same(clients[0], clients[1]);
        Assert.Equal(2, resolver.Created.Count);
        Assert.Equal(1, resolver.Created.Count(e => e.DisposedFlag));

        resolver.Dispose();
    }
    #endregion

    #region ApiHelper 扩展资源释放
    [Fact(DisplayName = "HttpClient扩展_普通结果释放响应_原始响应所有权移交")]
    public async Task ApiHelperInvoke_ResponseOwnership()
    {
        var normal = new TrackingContent("{\"code\":0,\"data\":\"ok\"}");
        var raw = new TrackingContent("raw-ok");
        var index = 0;
        var handler = new MockHttpMessageHandler
        {
            Handler = _ => index++ == 0
                ? new HttpResponseMessage(HttpStatusCode.OK) { Content = normal }
                : new HttpResponseMessage(HttpStatusCode.OK) { Content = raw }
        };
        var client = new HttpClient(handler) { BaseAddress = new Uri("http://127.0.0.1:8080") };

        // 普通结果:读取后立即释放响应
        var rs = await client.InvokeAsync<String>(HttpMethod.Get, "api/info");
        Assert.Equal("ok", rs);
        Assert.True(normal.Disposed);

        // 原始响应:所有权移交,不销毁
        var msg = await client.InvokeAsync<HttpResponseMessage>(HttpMethod.Get, "api/raw");
        Assert.NotNull(msg);
        Assert.Equal("raw-ok", await msg.Content.ReadAsStringAsync());
        Assert.False(raw.Disposed);
    }
    #endregion

    #region 竞速下载资源释放
    /// <summary>竞速下载专用:返回带哈希头的响应,内容与预期哈希不符以触发保存失败</summary>
    private class RaceDownloadClient : ApiHttpClient
    {
        public ConcurrentDictionary<String, TrackingContent> Contents { get; } = new();

        public RaceDownloadClient(String urls) : base(urls) => LoadBalanceMode = LoadBalanceMode.Race;

        protected override async Task<HttpResponseMessage> SendOnServiceAsync(HttpRequestMessage request, ServiceEndpoint service, HttpClient client, Boolean onlyHeader, CancellationToken cancellationToken)
        {
            // 让第一个节点先返回,稳定选中它作为下载节点
            if (service.Name != "service1") await Task.Delay(200, cancellationToken);

            var content = new TrackingContent("payload");
            Contents[service.Name] = content;

            var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
            response.Content.Headers.TryAddWithoutValidation("Content-SHA256", "sha256$" + new String('0', 64));
            return response;
        }
    }

    /// <summary>可控顺序的失败客户端:service1 的异常要等 service2 的异常冒泡之后才放行</summary>
    private class OrderedFailClient : ApiHttpClient
    {
        public readonly SemaphoreSlim FirstEntered = new(0);
        public readonly SemaphoreSlim ReleaseFirst = new(0);

        public OrderedFailClient(String urls) : base(urls) => LoadBalanceMode = LoadBalanceMode.RoundRobin;

        protected override async Task<HttpResponseMessage> SendOnServiceAsync(HttpRequestMessage request, ServiceEndpoint service, HttpClient client, Boolean onlyHeader, CancellationToken cancellationToken)
        {
            if (service.Name == "service1")
            {
                FirstEntered.Release();
                await ReleaseFirst.WaitAsync().ConfigureAwait(false);
            }

            throw new InvalidOperationException("boom-" + service.Name);
        }
    }

    [Fact(DisplayName = "并发请求_错误归因不串节点")]
    public async Task ConcurrentRequests_ErrorSourceNotCrossed()
    {
        var client = new OrderedFailClient("http://127.0.0.1:34567,http://127.0.0.1:34568");

        // 第一个请求先选中 service1 并挂起
        var t1 = Task.Run(() => client.InvokeAsync<String>("api/a", null));
        await client.FirstEntered.WaitAsync(TimeSpan.FromSeconds(10));

        // 第二个请求此时选中 service2 并立即以异常结束,改写共享状态的机会点
        var ex2 = await Assert.ThrowsAsync<InvalidOperationException>(() => client.InvokeAsync<String>("api/b", null));
        Assert.Contains("34568", ex2.Source);

        // 放行第一个请求:其归因必须仍指向 service1(旧实现读共享字段,会指向后来的 service2)
        client.ReleaseFirst.Release();
        var ex1 = await Assert.ThrowsAsync<InvalidOperationException>(() => t1);
        Assert.Contains("34567", ex1.Source);
    }

    [Fact(DisplayName = "空服务列表_抛带消息异常而不是静默成功")]
    public async Task EmptyServices_ThrowsWithMessage()
    {
        var client = new ApiHttpClient();

        // 普通调用:旧实现抛无消息 InvalidOperationException(注释还写“无法到达这里”)
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.InvokeAsync<String>("api/info", null));
        Assert.Contains("service address", ex.Message);

        // 下载:旧实现循环零次直接静默返回,调用方误以为成功
        var file = Path.Combine(Path.GetTempPath(), "empty-dl.bin");
        var ex2 = await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadFileAsync("http://x/file", file, null));
        Assert.Contains("service address", ex2.Message);
    }

    [Fact(DisplayName = "竞速下载_保存失败_响应仍被释放")]
    public async Task RaceDownload_SaveFailure_DisposesResponse()
    {
        var expectedHash = "sha256$" + new String('0', 64);
        var client = new RaceDownloadClient("http://127.0.0.1:34567,http://127.0.0.1:34568");

        var file = Path.GetTempFileName();
        File.Delete(file);
        try
        {
            await Assert.ThrowsAsync<IOException>(() => client.DownloadFileRaceAsync("file.bin", file, expectedHash));

            // 内容哈希必然不符,保存走异常路径:选中响应必须已释放(旧实现 Dispose 在保存之后,失败即跳过)
            Assert.True(client.Contents["service1"].Disposed);
        }
        finally
        {
            if (File.Exists(file)) File.Delete(file);
            if (File.Exists(file + ".tmp")) File.Delete(file + ".tmp");
        }
    }
    [Fact(DisplayName = "节点令牌按选中节点附加,不再串门")]
    public async Task NodeToken_AttachedPerNode()
    {
        var calls = new List<(Int32 Port, String? Auth)>();
        var handler = new MockHttpMessageHandler
        {
            Handler = request =>
            {
                var port = request.RequestUri!.Port;
                String? auth = request.Headers.Authorization?.ToString();
                lock (calls) calls.Add((port, auth));

                // 端口1视为故障节点,触发故障转移到端口99
                if (port == 1) throw new HttpRequestException("connection refused");

                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("{\"code\":0,\"data\":\"ok\"}")
                };
            }
        };

        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:1#token=AAA,http://127.0.0.1:99#token=BBB");
        var rs = await client.InvokeAsync<Object>(HttpMethod.Get, "api/info", null);

        Assert.Equal("ok", rs + "");
        Assert.Equal(2, calls.Count);

        // 第二个节点必须收到它自己的令牌,而不是上一个节点的
        Assert.Equal(99, calls[1].Port);
        Assert.Equal("Bearer BBB", calls[1].Auth);

        // 节点令牌不再写进客户端级共享属性
        Assert.Null(client.Token);
    }

    [Fact(DisplayName = "用户手动设置令牌不受节点令牌影响")]
    public async Task UserToken_NotOverwritten()
    {
        var handler = new MockHttpMessageHandler();
        var client = new TestableApiHttpClient(handler, "http://127.0.0.1:99")
        {
            Token = "USER"
        };

        var rs = await client.InvokeAsync<Object>(HttpMethod.Get, "api/info", null);

        Assert.NotNull(rs);
        Assert.Equal("Bearer USER", handler.LastRequest?.Headers.Authorization?.ToString());
        Assert.Equal("USER", client.Token);
    }
    #endregion
}