570 lines
21 KiB
C#
570 lines
21 KiB
C#
// Copyright 2012-2026 The NATS Authors
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
//
|
||
// Adapted from server/server_test.go in the NATS server Go source.
|
||
// Session 09–10: standalone unit tests for NatsServer helpers.
|
||
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using System.Net.Security;
|
||
using System.Threading;
|
||
using NSubstitute;
|
||
using NSubstitute.ExceptionExtensions;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.NatsNet.Server.Auth;
|
||
using ZB.MOM.NatsNet.Server.Internal;
|
||
|
||
namespace ZB.MOM.NatsNet.Server.Tests;
|
||
|
||
/// <summary>
|
||
/// Standalone unit tests for <see cref="NatsServer"/> helpers.
|
||
/// Tests that require a running server (listener, TLS, cluster) are marked n/a
|
||
/// and will be ported in sessions 10–23.
|
||
/// </summary>
|
||
public sealed class ServerTests
|
||
{
|
||
[Fact]
|
||
public void Init_CalledMultipleTimes_RemainsIdempotent()
|
||
{
|
||
var before = ServerConstants.GitCommit;
|
||
|
||
ServerConstants.Init();
|
||
ServerConstants.Init();
|
||
|
||
ServerConstants.GitCommit.ShouldBe(before);
|
||
}
|
||
|
||
// =========================================================================
|
||
// TestSemanticVersion — Test ID 2866
|
||
// Validates that ServerConstants.Version matches semver format.
|
||
// Mirrors Go TestSemanticVersion in server/server_test.go.
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void Version_IsValidSemVer()
|
||
{
|
||
// SemVer regex: major.minor.patch with optional pre-release / build meta.
|
||
var semVerRe = new Regex(@"^\d+\.\d+\.\d+(-\S+)?(\+\S+)?$", RegexOptions.Compiled);
|
||
semVerRe.IsMatch(ServerConstants.Version).ShouldBeTrue(
|
||
$"Version ({ServerConstants.Version}) is not a valid SemVer string");
|
||
}
|
||
|
||
// =========================================================================
|
||
// TestProcessCommandLineArgs — Test ID 2882
|
||
// Tests the ProcessCommandLineArgs helper.
|
||
// The Go version uses flag.FlagSet; our C# port takes string[].
|
||
// Mirrors Go TestProcessCommandLineArgs in server/server_test.go.
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ProcessCommandLineArgs_NoArgs_ReturnsFalse()
|
||
{
|
||
var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([]);
|
||
err.ShouldBeNull();
|
||
showVersion.ShouldBeFalse();
|
||
showHelp.ShouldBeFalse();
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("version", true, false)]
|
||
[InlineData("VERSION", true, false)]
|
||
[InlineData("help", false, true)]
|
||
[InlineData("HELP", false, true)]
|
||
public void ProcessCommandLineArgs_KnownSubcommand_ReturnsCorrectFlags(
|
||
string arg, bool wantVersion, bool wantHelp)
|
||
{
|
||
var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([arg]);
|
||
err.ShouldBeNull();
|
||
showVersion.ShouldBe(wantVersion);
|
||
showHelp.ShouldBe(wantHelp);
|
||
}
|
||
|
||
[Fact]
|
||
public void ProcessCommandLineArgs_UnknownSubcommand_ReturnsError()
|
||
{
|
||
var (_, _, err) = NatsServer.ProcessCommandLineArgs(["foo"]);
|
||
err.ShouldNotBeNull();
|
||
}
|
||
|
||
// =========================================================================
|
||
// CompressionMode helpers — standalone tests for features 2976–2982
|
||
// =========================================================================
|
||
|
||
[Theory]
|
||
[InlineData("off", CompressionMode.Off)]
|
||
[InlineData("false", CompressionMode.Off)]
|
||
[InlineData("accept", CompressionMode.Accept)]
|
||
[InlineData("s2_fast", CompressionMode.S2Fast)]
|
||
[InlineData("fast", CompressionMode.S2Fast)]
|
||
[InlineData("better", CompressionMode.S2Better)]
|
||
[InlineData("best", CompressionMode.S2Best)]
|
||
public void ValidateAndNormalizeCompressionOption_KnownModes_NormalizesCorrectly(
|
||
string input, string expected)
|
||
{
|
||
var co = new CompressionOpts { Mode = input };
|
||
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast);
|
||
co.Mode.ShouldBe(expected);
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidateAndNormalizeCompressionOption_OnAlias_MapsToChosenMode()
|
||
{
|
||
var co = new CompressionOpts { Mode = "on" };
|
||
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Better);
|
||
co.Mode.ShouldBe(CompressionMode.S2Better);
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidateAndNormalizeCompressionOption_S2Auto_UsesDefaults_WhenNoThresholds()
|
||
{
|
||
var co = new CompressionOpts { Mode = "s2_auto" };
|
||
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast);
|
||
co.Mode.ShouldBe(CompressionMode.S2Auto);
|
||
co.RttThresholds.ShouldBe(NatsServer.DefaultCompressionS2AutoRttThresholds.ToList());
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidateAndNormalizeCompressionOption_UnsupportedMode_Throws()
|
||
{
|
||
var co = new CompressionOpts { Mode = "bogus" };
|
||
Should.Throw<InvalidOperationException>(
|
||
() => NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(5, CompressionMode.S2Uncompressed)] // <= 10 ms threshold
|
||
[InlineData(25, CompressionMode.S2Fast)] // 10 < rtt <= 50 ms
|
||
[InlineData(75, CompressionMode.S2Better)] // 50 < rtt <= 100 ms
|
||
[InlineData(150, CompressionMode.S2Best)] // > 100 ms
|
||
public void SelectS2AutoModeBasedOnRtt_DefaultThresholds_CorrectMode(int rttMs, string expected)
|
||
{
|
||
var result = NatsServer.SelectS2AutoModeBasedOnRtt(
|
||
TimeSpan.FromMilliseconds(rttMs),
|
||
NatsServer.DefaultCompressionS2AutoRttThresholds);
|
||
result.ShouldBe(expected);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(CompressionMode.Off, CompressionMode.S2Fast, CompressionMode.Off)]
|
||
[InlineData(CompressionMode.Accept, CompressionMode.Accept, CompressionMode.Off)]
|
||
[InlineData(CompressionMode.S2Fast, CompressionMode.Accept, CompressionMode.S2Fast)]
|
||
[InlineData(CompressionMode.Accept, CompressionMode.S2Fast, CompressionMode.S2Fast)]
|
||
[InlineData(CompressionMode.Accept, CompressionMode.S2Auto, CompressionMode.S2Fast)]
|
||
public void SelectCompressionMode_TableDriven(string local, string remote, string expected)
|
||
{
|
||
NatsServer.SelectCompressionMode(local, remote).ShouldBe(expected);
|
||
}
|
||
|
||
[Fact]
|
||
public void SelectCompressionMode_RemoteNotSupported_ReturnsNotSupported()
|
||
{
|
||
NatsServer.SelectCompressionMode(CompressionMode.S2Fast, CompressionMode.NotSupported)
|
||
.ShouldBe(CompressionMode.NotSupported);
|
||
}
|
||
|
||
[Fact]
|
||
public void CompressOptsEqual_SameMode_ReturnsTrue()
|
||
{
|
||
var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast };
|
||
var c2 = new CompressionOpts { Mode = CompressionMode.S2Fast };
|
||
NatsServer.CompressOptsEqual(c1, c2).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void CompressOptsEqual_DifferentModes_ReturnsFalse()
|
||
{
|
||
var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast };
|
||
var c2 = new CompressionOpts { Mode = CompressionMode.S2Best };
|
||
NatsServer.CompressOptsEqual(c1, c2).ShouldBeFalse();
|
||
}
|
||
|
||
// =========================================================================
|
||
// Validation helpers
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ValidateCluster_ClusterNameWithSpaces_ReturnsError()
|
||
{
|
||
var opts = new ServerOptions();
|
||
opts.Cluster.Name = "bad name";
|
||
var err = NatsServer.ValidateCluster(opts);
|
||
err.ShouldNotBeNull();
|
||
err.ShouldBeSameAs(ServerErrors.ErrClusterNameHasSpaces);
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidatePinnedCerts_ValidSha256_ReturnsNull()
|
||
{
|
||
var pinned = new PinnedCertSet(
|
||
[new string('a', 64)]); // 64 hex chars
|
||
var err = NatsServer.ValidatePinnedCerts(pinned);
|
||
err.ShouldBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidatePinnedCerts_InvalidSha256_ReturnsError()
|
||
{
|
||
var pinned = new PinnedCertSet(["not_a_sha256"]);
|
||
var err = NatsServer.ValidatePinnedCerts(pinned);
|
||
err.ShouldNotBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void MatchesPinnedCert_NullPinnedSet_ReturnsTrue()
|
||
{
|
||
var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
|
||
client.MatchesPinnedCert(null).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void MatchesPinnedCert_NoTlsCertificate_ReturnsFalse()
|
||
{
|
||
var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
|
||
var pinned = new PinnedCertSet([new string('a', 64)]);
|
||
client.MatchesPinnedCert(pinned).ShouldBeFalse();
|
||
}
|
||
|
||
// =========================================================================
|
||
// GetServerProto
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void GetServerProto_DefaultOpts_ReturnsMsgTraceProto()
|
||
{
|
||
var opts = new ServerOptions();
|
||
// SetBaselineOptions so OverrideProto gets default 0.
|
||
opts.SetBaselineOptions();
|
||
var (s, err) = NatsServer.NewServer(opts);
|
||
err.ShouldBeNull();
|
||
s.ShouldNotBeNull();
|
||
s!.GetServerProto().ShouldBe(ServerProtocol.MsgTraceProto);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Account helpers
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeRoutePoolIdx_PoolSizeOne_AlwaysReturnsZero()
|
||
{
|
||
NatsServer.ComputeRoutePoolIdx(1, "any-account").ShouldBe(0);
|
||
NatsServer.ComputeRoutePoolIdx(0, "any-account").ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void ComputeRoutePoolIdx_PoolSizeN_ReturnsIndexInRange()
|
||
{
|
||
const int poolSize = 5;
|
||
var idx = NatsServer.ComputeRoutePoolIdx(poolSize, "my-account");
|
||
idx.ShouldBeInRange(0, poolSize - 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void NeedsCompression_Empty_ReturnsFalse()
|
||
=> NatsServer.NeedsCompression(string.Empty).ShouldBeFalse();
|
||
|
||
[Fact]
|
||
public void NeedsCompression_Off_ReturnsFalse()
|
||
=> NatsServer.NeedsCompression(CompressionMode.Off).ShouldBeFalse();
|
||
|
||
[Fact]
|
||
public void NeedsCompression_S2Fast_ReturnsTrue()
|
||
=> NatsServer.NeedsCompression(CompressionMode.S2Fast).ShouldBeTrue();
|
||
|
||
[Theory]
|
||
[InlineData(CompressionMode.S2Uncompressed, "writer_concurrency=1", "writer_uncompressed")]
|
||
[InlineData(CompressionMode.S2Best, "writer_concurrency=1", "writer_best_compression")]
|
||
[InlineData(CompressionMode.S2Better, "writer_concurrency=1", "writer_better_compression")]
|
||
public void S2WriterOptions_KnownModes_ReturnExpectedOptions(
|
||
string mode,
|
||
string expectedFirst,
|
||
string expectedSecond)
|
||
{
|
||
var options = NatsServer.S2WriterOptions(mode);
|
||
|
||
options.ShouldNotBeNull();
|
||
options!.ShouldBe([expectedFirst, expectedSecond]);
|
||
}
|
||
|
||
[Fact]
|
||
public void S2WriterOptions_UnsupportedMode_ReturnsNull()
|
||
{
|
||
NatsServer.S2WriterOptions(CompressionMode.S2Fast).ShouldBeNull();
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP
|
||
// =============================================================================
|
||
|
||
/// <summary>
|
||
/// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers,
|
||
/// CopyInfo, and GetRandomIP.
|
||
/// </summary>
|
||
public sealed class ServerListenersTests
|
||
{
|
||
[Fact]
|
||
public void TlsTimeout_IncompleteHandshake_ClosesConnection()
|
||
{
|
||
var c = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
|
||
using var tls = new SslStream(new MemoryStream(), leaveInnerStreamOpen: false);
|
||
|
||
c.IsClosed().ShouldBeFalse();
|
||
|
||
NatsServer.TlsTimeout(c, tls);
|
||
|
||
c.IsClosed().ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void StartGoRoutine_WithLabels_InvokesSetGoRoutineLabels()
|
||
{
|
||
var (s, err) = NatsServer.NewServer(new ServerOptions());
|
||
err.ShouldBeNull();
|
||
s.ShouldNotBeNull();
|
||
s!.Start();
|
||
|
||
var signal = new ManualResetEventSlim(false);
|
||
IReadOnlyList<KeyValuePair<string, string>>? observed = null;
|
||
|
||
NatsServer.SetGoRoutineLabelsHookForTest = labels =>
|
||
{
|
||
observed = labels;
|
||
signal.Set();
|
||
};
|
||
|
||
try
|
||
{
|
||
var started = s.StartGoRoutine(
|
||
() => { },
|
||
new Dictionary<string, string> { ["component"] = "server", ["loop"] = "tls" });
|
||
|
||
started.ShouldBeTrue();
|
||
signal.Wait(TimeSpan.FromSeconds(2)).ShouldBeTrue();
|
||
|
||
observed.ShouldNotBeNull();
|
||
observed!.ShouldContain(kv => kv.Key == "component" && kv.Value == "server");
|
||
observed.ShouldContain(kv => kv.Key == "loop" && kv.Value == "tls");
|
||
}
|
||
finally
|
||
{
|
||
NatsServer.SetGoRoutineLabelsHookForTest = null;
|
||
s.Shutdown();
|
||
s.WaitForShutdown();
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// GenerateInfoJson (feature 3069) — Test ID 2906
|
||
// Mirrors Go TestServerJsonMarshalNestedStructsPanic (guards against
|
||
// marshaller panics with nested/nullable structs).
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void GenerateInfoJson_MinimalInfo_ProducesInfoFrame()
|
||
{
|
||
var info = new ServerInfo { Id = "TEST", Version = "1.0.0", Host = "0.0.0.0", Port = 4222 };
|
||
var bytes = NatsServer.GenerateInfoJson(info);
|
||
var text = Encoding.UTF8.GetString(bytes);
|
||
text.ShouldStartWith("INFO {");
|
||
text.ShouldEndWith("}\r\n");
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateInfoJson_WithConnectUrls_IncludesUrls()
|
||
{
|
||
var info = new ServerInfo
|
||
{
|
||
Id = "TEST",
|
||
Version = "1.0.0",
|
||
ClientConnectUrls = ["nats://127.0.0.1:4222", "nats://127.0.0.1:4223"],
|
||
};
|
||
var text = Encoding.UTF8.GetString(NatsServer.GenerateInfoJson(info));
|
||
text.ShouldContain("connect_urls");
|
||
text.ShouldContain("4222");
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateInfoJson_WithSubjectPermissions_DoesNotThrow()
|
||
{
|
||
// Mirrors Go TestServerJsonMarshalNestedStructsPanic — guards against
|
||
// JSON marshaller failures with nested nullable structs.
|
||
var info = new ServerInfo
|
||
{
|
||
Id = "TEST",
|
||
Version = "1.0.0",
|
||
Import = new SubjectPermission { Allow = ["foo.>"], Deny = ["bar"] },
|
||
Export = new SubjectPermission { Allow = ["pub.>"] },
|
||
};
|
||
var bytes = NatsServer.GenerateInfoJson(info);
|
||
bytes.ShouldNotBeEmpty();
|
||
// Strip the "INFO " prefix (5 bytes) and the trailing "\r\n" (2 bytes) to get pure JSON.
|
||
var json = Encoding.UTF8.GetString(bytes, 5, bytes.Length - 7);
|
||
var doc = JsonDocument.Parse(json);
|
||
doc.RootElement.GetProperty("import").ValueKind.ShouldBe(JsonValueKind.Object);
|
||
}
|
||
|
||
// =========================================================================
|
||
// TlsVersion helpers (features 3079–3080)
|
||
// =========================================================================
|
||
|
||
[Theory]
|
||
[InlineData(0x0301u, "1.0")]
|
||
[InlineData(0x0302u, "1.1")]
|
||
[InlineData(0x0303u, "1.2")]
|
||
[InlineData(0x0304u, "1.3")]
|
||
public void TlsVersion_KnownCodes_ReturnsVersionString(uint ver, string expected)
|
||
=> NatsServer.TlsVersion((ushort)ver).ShouldBe(expected);
|
||
|
||
[Fact]
|
||
public void TlsVersion_UnknownCode_ReturnsUnknownLabel()
|
||
=> NatsServer.TlsVersion(0xFFFF).ShouldStartWith("Unknown");
|
||
|
||
[Theory]
|
||
[InlineData("1.0", (ushort)0x0301)]
|
||
[InlineData("1.1", (ushort)0x0302)]
|
||
[InlineData("1.2", (ushort)0x0303)]
|
||
[InlineData("1.3", (ushort)0x0304)]
|
||
public void TlsVersionFromString_KnownStrings_ReturnsCode(string input, ushort expected)
|
||
{
|
||
var (ver, err) = NatsServer.TlsVersionFromString(input);
|
||
err.ShouldBeNull();
|
||
ver.ShouldBe(expected);
|
||
}
|
||
|
||
[Fact]
|
||
public void TlsVersionFromString_UnknownString_ReturnsError()
|
||
{
|
||
var (_, err) = NatsServer.TlsVersionFromString("9.9");
|
||
err.ShouldNotBeNull();
|
||
}
|
||
|
||
// =========================================================================
|
||
// CopyInfo (feature 3069)
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void CopyInfo_DeepCopiesSlices()
|
||
{
|
||
var (s, err) = NatsServer.NewServer(new ServerOptions());
|
||
err.ShouldBeNull();
|
||
s.ShouldNotBeNull();
|
||
s!._info.ClientConnectUrls = ["nats://127.0.0.1:4222"];
|
||
|
||
var copy = s.CopyInfo();
|
||
copy.ClientConnectUrls.ShouldNotBeNull();
|
||
copy.ClientConnectUrls!.ShouldBe(["nats://127.0.0.1:4222"]);
|
||
// Mutating original slice shouldn't affect the copy.
|
||
s._info.ClientConnectUrls = ["nats://10.0.0.1:4222"];
|
||
copy.ClientConnectUrls[0].ShouldBe("nats://127.0.0.1:4222");
|
||
}
|
||
|
||
// =========================================================================
|
||
// GetRandomIP (feature 3141) — Test ID 2895
|
||
// Mirrors Go TestGetRandomIP.
|
||
// =========================================================================
|
||
|
||
private static NatsServer MakeServer()
|
||
{
|
||
var (s, err) = NatsServer.NewServer(new ServerOptions());
|
||
err.ShouldBeNull();
|
||
return s!;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_NoPort_ReturnsFormatError()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
var (_, err) = await s.GetRandomIP(resolver, "noport");
|
||
err.ShouldNotBeNull();
|
||
err!.Message.ShouldContain("port");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_ResolverThrows_PropagatesError()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
resolver.LookupHostAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||
.ThrowsAsync(new InvalidOperationException("on purpose"));
|
||
var (_, err) = await s.GetRandomIP(resolver, "localhost:4222");
|
||
err.ShouldNotBeNull();
|
||
err!.Message.ShouldContain("on purpose");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_EmptyIps_ReturnsFallbackUrl()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(Array.Empty<string>()));
|
||
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
|
||
err.ShouldBeNull();
|
||
addr.ShouldBe("localhost:4222");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_SingleIp_ReturnsMappedAddress()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new[] { "1.2.3.4" }));
|
||
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
|
||
err.ShouldBeNull();
|
||
addr.ShouldBe("1.2.3.4:4222");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_MultipleIps_AllSelectedWithinRange()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
|
||
|
||
var dist = new int[3];
|
||
for (int i = 0; i < 100; i++)
|
||
{
|
||
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
|
||
err.ShouldBeNull();
|
||
dist[int.Parse(addr[..1]) - 1]++;
|
||
}
|
||
|
||
// Each IP should appear at least once and no single IP should dominate.
|
||
foreach (var d in dist)
|
||
d.ShouldBeGreaterThan(0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetRandomIP_ExcludedIp_NeverReturned()
|
||
{
|
||
var s = MakeServer();
|
||
var resolver = Substitute.For<INetResolver>();
|
||
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
|
||
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
|
||
|
||
var excluded = new HashSet<string> { "1.2.3.4:4222" };
|
||
for (int i = 0; i < 100; i++)
|
||
{
|
||
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222", excluded);
|
||
err.ShouldBeNull();
|
||
addr.ShouldNotBe("1.2.3.4:4222");
|
||
}
|
||
}
|
||
}
|