diff --git a/sources/Valkey.Glide/Abstract/ConfigurationOptions.cs b/sources/Valkey.Glide/Abstract/ConfigurationOptions.cs index 987306f..cecc052 100644 --- a/sources/Valkey.Glide/Abstract/ConfigurationOptions.cs +++ b/sources/Valkey.Glide/Abstract/ConfigurationOptions.cs @@ -52,7 +52,9 @@ internal const string Ssl = "ssl", Version = "version", SetClientLibrary = "setlib", - Protocol = "protocol" + Protocol = "protocol", + ReadFrom = "readFrom", + Az = "az" ; private static readonly Dictionary normalizedOptions = new[] @@ -71,6 +73,8 @@ internal const string Version, SetClientLibrary, Protocol, + ReadFrom, + Az }.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase); public static string TryNormalize(string value) @@ -83,13 +87,11 @@ public static string TryNormalize(string value) } } + #region Private fields private bool? ssl; - private Proxy? proxy; - private RetryStrategy? reconnectRetryPolicy; - - private ReadFrom? readFrom; + #endregion /// /// Gets or sets whether connect/configuration timeouts should be explicitly notified via a TimeoutException. @@ -240,11 +242,7 @@ public RetryStrategy? ReconnectRetryPolicy /// /// The read from strategy and Availability zone if applicable. /// - public ReadFrom? ReadFrom - { - get => readFrom; - set => readFrom = value; - } + public ReadFrom? ReadFrom { get; set; } /// /// Indicates whether endpoints should be resolved via DNS before connecting. @@ -306,9 +304,9 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow ResponseTimeout = ResponseTimeout, DefaultDatabase = DefaultDatabase, reconnectRetryPolicy = reconnectRetryPolicy, - readFrom = readFrom, EndPoints = EndPoints.Clone(), Protocol = Protocol, + ReadFrom = ReadFrom }; /// @@ -356,6 +354,10 @@ public string ToString(bool includePassword) Append(sb, OptionKeys.ResponseTimeout, ResponseTimeout); Append(sb, OptionKeys.DefaultDatabase, DefaultDatabase); Append(sb, OptionKeys.Protocol, FormatProtocol(Protocol)); + if (ReadFrom.HasValue) + { + FormatReadFrom(sb, ReadFrom.Value); + } return sb.ToString(); @@ -401,7 +403,7 @@ private void Clear() ClientName = User = Password = null; ConnectTimeout = ResponseTimeout = null; ssl = null; - readFrom = null; + ReadFrom = null; reconnectRetryPolicy = null; EndPoints.Clear(); } @@ -410,6 +412,9 @@ private void Clear() private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown = true) { + ReadFromStrategy? tempReadFromStrategy = null; + string? tempAz = null; + if (configuration == null) { throw new ArgumentNullException(nameof(configuration)); @@ -463,6 +468,12 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown = case OptionKeys.ResponseTimeout: ResponseTimeout = OptionKeys.ParseInt32(key, value); break; + case OptionKeys.ReadFrom: + tempReadFromStrategy = CheckReadFromValue(value); + break; + case OptionKeys.Az: + tempAz = CheckAzValue(value); + break; default: if (!ignoreUnknown) throw new ArgumentException($"Keyword '{key}' is not supported.", key); break; @@ -476,9 +487,71 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown = } } } + + // Validate ReadFrom configuration after all parameters have been parsed + if (tempReadFromStrategy.HasValue) + { + ReadFrom = SetReadFrom(tempReadFromStrategy, tempAz); + } + return this; } + private string CheckAzValue(string az) + { + if (string.IsNullOrWhiteSpace(az)) + { + throw new ArgumentException("Availability zone cannot be empty or whitespace"); + } + return az; + } + + private ReadFrom? SetReadFrom(ReadFromStrategy? strategy, string? az) + { + if (strategy.HasValue) + { + // Use ReadFrom constructors based on strategy type - the constructors contain the validation logic + return strategy.Value switch + { + ReadFromStrategy.AzAffinity or ReadFromStrategy.AzAffinityReplicasAndPrimary => new ReadFrom(strategy.Value, az!), + ReadFromStrategy.Primary or ReadFromStrategy.PreferReplica => new ReadFrom(strategy.Value), + _ => throw new ArgumentException($"ReadFrom strategy '{strategy.Value}' is not supported. Valid strategies are: Primary, PreferReplica, AzAffinity, AzAffinityReplicasAndPrimary"), + }; + } + return null; + } + + private ReadFromStrategy CheckReadFromValue(string readFrom) + { + if (string.IsNullOrWhiteSpace(readFrom)) + { + throw new ArgumentException("ReadFrom strategy cannot be empty"); + } + + try + { + return Enum.Parse(readFrom, ignoreCase: true); + } + catch (ArgumentException) + { + throw new ArgumentException($"ReadFrom strategy '{readFrom}' is not supported. Valid strategies are: Primary, PreferReplica, AzAffinity, AzAffinityReplicasAndPrimary"); + } + } + + /// + /// Formats a ReadFrom struct to its string representation and appends it to the StringBuilder. + /// + /// The StringBuilder to append to. + /// The ReadFrom configuration to format. + private static void FormatReadFrom(StringBuilder sb, ReadFrom readFromConfig) + { + Append(sb, OptionKeys.ReadFrom, readFromConfig.Strategy.ToString()); + if (!string.IsNullOrWhiteSpace(readFromConfig.Az)) + { + Append(sb, OptionKeys.Az, readFromConfig.Az); + } + } + /// /// Specify the connection protocol type. /// diff --git a/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs b/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs index fb2168e..d820a31 100644 --- a/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs +++ b/sources/Valkey.Glide/Abstract/ConnectionMultiplexer.cs @@ -159,7 +159,7 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, Database db) _db = db; } - private static T CreateClientConfigBuilder(ConfigurationOptions configuration) + internal static T CreateClientConfigBuilder(ConfigurationOptions configuration) where T : ClientConfigurationBuilder, new() { T config = new(); diff --git a/sources/Valkey.Glide/ConnectionConfiguration.cs b/sources/Valkey.Glide/ConnectionConfiguration.cs index 65a4737..6646914 100644 --- a/sources/Valkey.Glide/ConnectionConfiguration.cs +++ b/sources/Valkey.Glide/ConnectionConfiguration.cs @@ -103,6 +103,10 @@ public ReadFrom(ReadFromStrategy strategy, string az) { throw new ArgumentException("Availability zone could be set only when using `AzAffinity` or `AzAffinityReplicasAndPrimary` strategy."); } + if (string.IsNullOrWhiteSpace(az)) + { + throw new ArgumentException("Availability zone cannot be empty or whitespace"); + } Strategy = strategy; Az = az; } diff --git a/sources/Valkey.Glide/Internals/FFI.methods.cs b/sources/Valkey.Glide/Internals/FFI.methods.cs index a68d472..0d8d7de 100644 --- a/sources/Valkey.Glide/Internals/FFI.methods.cs +++ b/sources/Valkey.Glide/Internals/FFI.methods.cs @@ -1,7 +1,9 @@ // Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 // check https://stackoverflow.com/a/77455034 if you're getting analyzer error (using is unnecessary) +#if NET8_0_OR_GREATER using System.Runtime.CompilerServices; +#endif using System.Runtime.InteropServices; namespace Valkey.Glide.Internals; diff --git a/tests/Valkey.Glide.IntegrationTests/AzAffinityTests.cs b/tests/Valkey.Glide.IntegrationTests/AzAffinityTests.cs new file mode 100644 index 0000000..dfcbbf4 --- /dev/null +++ b/tests/Valkey.Glide.IntegrationTests/AzAffinityTests.cs @@ -0,0 +1,249 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using System.Text.RegularExpressions; + +using static Valkey.Glide.Commands.Options.InfoOptions; +using static Valkey.Glide.ConnectionConfiguration; +using static Valkey.Glide.Route; + +namespace Valkey.Glide.IntegrationTests; + +[Collection(typeof(AzAffinityTests))] +[CollectionDefinition(DisableParallelization = true)] +public class AzAffinityTests(TestConfiguration config) +{ + public TestConfiguration Config { get; } = config; + + private static async Task CreateAzTestClient(ReadFromStrategy strategy, string az, ConnectionConfiguration.Protocol protocol) + { + ClusterClientConfiguration config = TestConfiguration.DefaultClusterClientConfig() + .WithReadFrom(new(strategy, az)) + .WithRequestTimeout(TimeSpan.FromSeconds(2)) + .WithProtocolVersion(protocol) + .Build(); + return await GlideClusterClient.CreateClient(config); + } + + private static async Task GetReplicaCountInCluster(GlideClusterClient client) + { + ClusterValue clusterInfo = await client.Info([Section.REPLICATION], new SlotKeyRoute("_", SlotType.Primary)); + foreach (string line in clusterInfo.SingleValue!.Split('\n')) + { + string[] parts = line.Split(':', 2); + if (parts.Length == 2 && parts[0].Trim() == "connected_slaves") + { + return int.Parse(parts[1].Trim()); + } + } + throw new Exception("Can't get replica count"); + } + + [Theory] + [InlineData(ConnectionConfiguration.Protocol.RESP2)] + [InlineData(ConnectionConfiguration.Protocol.RESP3)] + public async Task TestRoutingWithAzAffinityStrategyTo1Replica(ConnectionConfiguration.Protocol protocol) + { + Assert.SkipWhen(TestConfiguration.SERVER_VERSION < new Version("8.0.0"), "AZ affinity requires server version 8.0.0 or higher"); + + using GlideClusterClient configClient = await GlideClusterClient.CreateClient( + TestConfiguration.DefaultClusterClientConfig().WithProtocolVersion(protocol).Build()); + const string az = "us-east-1a"; + const int nGetCalls = 3; + string key = Guid.NewGuid().ToString(); + + // Reset the availability zone for all nodes + await configClient.CustomCommand(["config", "set", "availability-zone", ""], AllNodes); + await configClient.CustomCommand(["config", "resetstat"], AllNodes); + await configClient.CustomCommand(["config", "set", "availability-zone", az], new SlotKeyRoute(key, SlotType.Replica)); + + using GlideClusterClient azTestClient = await CreateAzTestClient(ReadFromStrategy.AzAffinity, az, protocol); + + for (int i = 0; i < nGetCalls; i++) + { + await azTestClient.StringGetAsync(key); + } + + ClusterValue infoResult = await azTestClient.Info([Section.SERVER, Section.COMMANDSTATS], AllNodes); + azTestClient.Dispose(); + + int changedAzCount = 0; + foreach (string value in infoResult.MultiValue.Values) + { + Match m = Regex.Match(value, @"cmdstat_get:calls=(\d+)"); + if (value.Contains($"availability_zone:{az}")) + { + changedAzCount++; + if (value.Contains("role:slave") && m.Success) + { + Assert.Equal(nGetCalls, int.Parse(m.Groups[1].Value)); + } + } + else + { + if (m.Success) + { + Assert.Fail($"Non AZ replica got {m.Groups[1].Value} get calls"); + } + } + } + + // Check that the other replicas have no availability zone set + Assert.Equal(1, changedAzCount); + } + + [Theory] + [InlineData(ConnectionConfiguration.Protocol.RESP2)] + [InlineData(ConnectionConfiguration.Protocol.RESP3)] + public async Task TestRoutingBySlotToReplicaWithAzAffinityStrategyToAllReplicas(ConnectionConfiguration.Protocol protocol) + { + Assert.SkipWhen(TestConfiguration.SERVER_VERSION < new Version("8.0.0"), "AZ affinity requires server version 8.0.0 or higher"); + + using GlideClusterClient configClient = await GlideClusterClient.CreateClient( + TestConfiguration.DefaultClusterClientConfig().WithProtocolVersion(protocol).Build()); + const string az = "us-east-1a"; + string key = Guid.NewGuid().ToString(); + + // Reset the availability zone for all nodes + await configClient.CustomCommand(["config", "set", "availability-zone", ""], AllNodes); + await configClient.CustomCommand(["config", "resetstat"], AllNodes); + + // Get Replica Count for current cluster + ClusterValue clusterInfo = await configClient.Info([Section.REPLICATION], new SlotKeyRoute(key, SlotType.Primary)); + int nReplicas = await GetReplicaCountInCluster(configClient); + int nCallsPerReplica = 5; + int nGetCalls = nCallsPerReplica * nReplicas; + + // Setting AZ for all Nodes + await configClient.CustomCommand(["config", "set", "availability-zone", az], AllNodes); + + using GlideClusterClient azTestClient = await CreateAzTestClient(ReadFromStrategy.AzAffinity, az, protocol); + + ClusterValue azGetResult = await azTestClient.CustomCommand(["config", "get", "availability-zone"], AllNodes); + foreach (object? value in azGetResult.MultiValue.Values) + { + object[]? configArray = value as object[]; + if (configArray != null && configArray.Length >= 2) + { + Assert.Equal(az, configArray[1]?.ToString()); + } + } + + // Execute GET commands + for (int i = 0; i < nGetCalls; i++) + { + await azTestClient.StringGetAsync(key); + } + + ClusterValue infoResult = await azTestClient.Info([Section.ALL], AllNodes); + azTestClient.Dispose(); + + // Check that all replicas have the same number of GET calls + foreach (string value in infoResult.MultiValue.Values) + { + Match m = Regex.Match(value, @"cmdstat_get:calls=(\d+)"); + if (value.Contains("role:slave") && m.Success) + { + Assert.Equal(nCallsPerReplica, int.Parse(m.Groups[1].Value)); + } + } + } + + [Theory] + [InlineData(ConnectionConfiguration.Protocol.RESP2)] + [InlineData(ConnectionConfiguration.Protocol.RESP3)] + public async Task TestAzAffinityNonExistingAz(ConnectionConfiguration.Protocol protocol) + { + Assert.SkipWhen(TestConfiguration.SERVER_VERSION < new Version("8.0.0"), "AZ affinity requires server version 8.0.0 or higher"); + + const int nGetCalls = 3; + + using GlideClusterClient azTestClient = await CreateAzTestClient(ReadFromStrategy.AzAffinity, "non-existing-az", protocol); + + // Reset stats + await azTestClient.CustomCommand(["config", "resetstat"], AllNodes); + + // Execute GET commands + for (int i = 0; i < nGetCalls; i++) + { + await azTestClient.StringGetAsync("foo"); + } + + ClusterValue infoResult = await azTestClient.Info([Section.COMMANDSTATS], AllNodes); + azTestClient.Dispose(); + + // We expect the calls to be distributed evenly among the replicas + foreach (string value in infoResult.MultiValue.Values) + { + Match m = Regex.Match(value, @"cmdstat_get:calls=(\d+)"); + if (value.Contains("role:slave") && m.Success) + { + Assert.Equal(1, int.Parse(m.Groups[1].Value)); + } + } + } + + [Theory] + [InlineData(ConnectionConfiguration.Protocol.RESP2)] + [InlineData(ConnectionConfiguration.Protocol.RESP3)] + public async Task TestAzAffinityReplicasAndPrimaryRoutesToPrimary(ConnectionConfiguration.Protocol protocol) + { + Assert.SkipWhen(TestConfiguration.SERVER_VERSION < new Version("8.0.0"), "AZ affinity requires server version 8.0.0 or higher"); + + using GlideClusterClient configClient = await GlideClusterClient.CreateClient( + TestConfiguration.DefaultClusterClientConfig().WithProtocolVersion(protocol).Build()); + const string az = "us-east-1a"; + const string otherAz = "us-east-1b"; + int nReplicas = await GetReplicaCountInCluster(configClient); + string key = Guid.NewGuid().ToString(); + + // Reset stats and set all nodes to otherAz + await configClient.CustomCommand(["config", "resetstat"], AllNodes); + await configClient.CustomCommand(["config", "set", "availability-zone", otherAz], AllNodes); + + // Set primary which holds the key to az + await configClient.CustomCommand(["config", "set", "availability-zone", az], new SlotKeyRoute(key, SlotType.Primary)); + + // Verify primary AZ + ClusterValue primaryAzResult = await configClient.CustomCommand(["config", "get", "availability-zone"], new SlotKeyRoute(key, SlotType.Primary)); + object[]? primaryConfigArray = primaryAzResult.SingleValue as object[]; + if (primaryConfigArray != null && primaryConfigArray.Length >= 2) + { + Assert.Equal(az, primaryConfigArray[1]?.ToString()); + } + + using GlideClusterClient azTestClient = await CreateAzTestClient(ReadFromStrategy.AzAffinityReplicasAndPrimary, az, protocol); + + // Execute GET commands + for (int i = 0; i < nReplicas; i++) + { + await azTestClient.StringGetAsync(key); + } + + ClusterValue infoResult = await azTestClient.Info([Section.ALL], AllNodes); + azTestClient.Dispose(); + + // Check that only the primary in the specified AZ handled all GET calls + foreach (string value in infoResult.MultiValue.Values) + { + Match m = Regex.Match(value, @"cmdstat_get:calls=(\d+)"); + if (value.Contains(az)) + { + if (value.Contains("role:slave") && m.Success) + { + Assert.Fail($"Replica node got GET {m.Groups[1].Value} calls when shouldn't be"); + } + if (value.Contains("role:master")) + { + if (m.Success) + { + Assert.Equal(nReplicas, int.Parse(m.Groups[1].Value)); + } + else + { + Assert.Fail($"Primary node didn't get GET calls"); + } + } + } + } + } +} diff --git a/tests/Valkey.Glide.IntegrationTests/ConnectionMultiplexerReadFromMappingTests.cs b/tests/Valkey.Glide.IntegrationTests/ConnectionMultiplexerReadFromMappingTests.cs new file mode 100644 index 0000000..8fc8f62 --- /dev/null +++ b/tests/Valkey.Glide.IntegrationTests/ConnectionMultiplexerReadFromMappingTests.cs @@ -0,0 +1,360 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.IntegrationTests; + +/// +/// Integration tests for ConnectionMultiplexer ReadFrom mapping functionality. +/// These tests verify that ReadFrom configuration flows correctly from ConfigurationOptions +/// through to the ClientConfigurationBuilder and ConnectionConfig levels. +/// +[Collection(typeof(ConnectionMultiplexerReadFromMappingTests))] +[CollectionDefinition(DisableParallelization = true)] +public class ConnectionMultiplexerReadFromMappingTests(TestConfiguration config) +{ + public TestConfiguration Config { get; } = config; + + [Fact] + public async Task ConfigurationOptions_ReadFromPrimary_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.Primary) + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.Primary, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromPreferReplica_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.PreferReplica) + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.PreferReplica, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromAzAffinity_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + const string testAz = "us-east-1a"; + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, testAz) + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinity, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromAzAffinityReplicasAndPrimary_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + const string testAz = "eu-west-1b"; + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, testAz) + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromPrimary_MapsToClusterClientConfigurationBuilder() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.Primary) + }; + configOptions.EndPoints.Add(TestConfiguration.CLUSTER_HOSTS[0].host, TestConfiguration.CLUSTER_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.Primary, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromPreferReplica_MapsToClusterClientConfigurationBuilder() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.PreferReplica) + }; + configOptions.EndPoints.Add(TestConfiguration.CLUSTER_HOSTS[0].host, TestConfiguration.CLUSTER_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.PreferReplica, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromAzAffinity_MapsToClusterClientConfigurationBuilder() + { + // Arrange + const string testAz = "us-west-2a"; + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, testAz) + }; + configOptions.EndPoints.Add(TestConfiguration.CLUSTER_HOSTS[0].host, TestConfiguration.CLUSTER_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinity, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_ReadFromAzAffinityReplicasAndPrimary_MapsToClusterClientConfigurationBuilder() + { + // Arrange + const string testAz = "ap-south-1c"; + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, testAz) + }; + configOptions.EndPoints.Add(TestConfiguration.CLUSTER_HOSTS[0].host, TestConfiguration.CLUSTER_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConfigurationOptions_NullReadFrom_DefaultsToNullInStandaloneClient() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = null + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.False(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + } + } + + [Fact] + public async Task ConfigurationOptions_NullReadFrom_DefaultsToNullInClusterClient() + { + // Arrange + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = null + }; + configOptions.EndPoints.Add(TestConfiguration.CLUSTER_HOSTS[0].host, TestConfiguration.CLUSTER_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.False(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + } + } + + [Fact] + public async Task ConnectionString_ReadFromPrimary_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + string connectionString = $"{TestConfiguration.STANDALONE_HOSTS[0].host}:{TestConfiguration.STANDALONE_HOSTS[0].port},readFrom=Primary,ssl={TestConfiguration.TLS}"; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.Primary, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConnectionString_ReadFromAzAffinity_MapsToStandaloneClientConfigurationBuilder() + { + // Arrange + const string testAz = "us-east-1a"; + string connectionString = $"{TestConfiguration.STANDALONE_HOSTS[0].host}:{TestConfiguration.STANDALONE_HOSTS[0].port},readFrom=AzAffinity,az={testAz},ssl={TestConfiguration.TLS}"; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinity, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task ConnectionString_ReadFromAzAffinity_MapsToClusterClientConfigurationBuilder() + { + // Arrange + const string testAz = "eu-west-1b"; + string connectionString = $"{TestConfiguration.CLUSTER_HOSTS[0].host}:{TestConfiguration.CLUSTER_HOSTS[0].port},readFrom=AzAffinity,az={testAz},ssl={TestConfiguration.TLS}"; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(ReadFromStrategy.AzAffinity, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Theory] + [InlineData(ReadFromStrategy.Primary, null)] + [InlineData(ReadFromStrategy.PreferReplica, null)] + [InlineData(ReadFromStrategy.AzAffinity, "us-east-1a")] + [InlineData(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1b")] + public async Task EndToEnd_ReadFromConfiguration_FlowsFromConnectionStringToConnectionConfig(ReadFromStrategy strategy, string? az) + { + // Arrange + string connectionString = TestConfiguration.STANDALONE_HOSTS[0].host + ":" + TestConfiguration.STANDALONE_HOSTS[0].port; + connectionString += ",ssl=" + TestConfiguration.TLS; + + switch (strategy) + { + case ReadFromStrategy.Primary: + connectionString += ",readFrom=Primary"; + break; + case ReadFromStrategy.PreferReplica: + connectionString += ",readFrom=PreferReplica"; + break; + case ReadFromStrategy.AzAffinity: + connectionString += $",readFrom=AzAffinity,az={az}"; + break; + case ReadFromStrategy.AzAffinityReplicasAndPrimary: + connectionString += $",readFrom=AzAffinityReplicasAndPrimary,az={az}"; + break; + default: + throw new ArgumentException("Invalid ReadFromStrategy for this test"); + } + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.True(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + Assert.Equal(strategy, connectionMultiplexer.RawConfig.ReadFrom.Value.Strategy); + Assert.Equal(az, connectionMultiplexer.RawConfig.ReadFrom.Value.Az); + } + } + + [Fact] + public async Task EndToEnd_NoReadFromConfiguration_DefaultsToNull() + { + // Arrange + string connectionString = $"{TestConfiguration.STANDALONE_HOSTS[0].host}:{TestConfiguration.STANDALONE_HOSTS[0].port},ssl={TestConfiguration.TLS}"; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert + Assert.NotNull(connectionMultiplexer); + Assert.NotNull(connectionMultiplexer.RawConfig); + Assert.False(connectionMultiplexer.RawConfig.ReadFrom.HasValue); + } + } +} diff --git a/tests/Valkey.Glide.IntegrationTests/ReadFromTests.cs b/tests/Valkey.Glide.IntegrationTests/ReadFromTests.cs new file mode 100644 index 0000000..f63e05c --- /dev/null +++ b/tests/Valkey.Glide.IntegrationTests/ReadFromTests.cs @@ -0,0 +1,283 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.IntegrationTests; + +/// +/// End-to-end integration tests for ReadFrom configuration flowing from connection string to FFI layer. +/// These tests verify the complete configuration pipeline and error handling scenarios. +/// Implements task 12 from the AZ Affinity support implementation plan. +/// +[Collection(typeof(ReadFromTests))] +[CollectionDefinition(DisableParallelization = true)] +public class ReadFromTests(TestConfiguration config) +{ + public TestConfiguration Config { get; } = config; + + #region Connection String to FFI Layer Tests + + [Theory] + [InlineData("Primary", ReadFromStrategy.Primary, null, true)] + [InlineData("PreferReplica", ReadFromStrategy.PreferReplica, null, true)] + [InlineData("AzAffinity", ReadFromStrategy.AzAffinity, "us-east-1a", true)] + [InlineData("AzAffinityReplicasAndPrimary", ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1b", true)] + [InlineData("Primary", ReadFromStrategy.Primary, null, false)] + [InlineData("PreferReplica", ReadFromStrategy.PreferReplica, null, false)] + [InlineData("AzAffinity", ReadFromStrategy.AzAffinity, "ap-south-1c", false)] + [InlineData("AzAffinityReplicasAndPrimary", ReadFromStrategy.AzAffinityReplicasAndPrimary, "us-west-2b", false)] + public async Task ConnectionString_ReadFromConfigurationFlowsToFFILayer( + string strategyString, ReadFromStrategy expectedStrategy, string? expectedAz, bool useStandalone) + { + // Arrange + (string host, int port) hostConfig = useStandalone ? TestConfiguration.STANDALONE_HOSTS[0] : TestConfiguration.CLUSTER_HOSTS[0]; + string connectionString = $"{hostConfig.host}:{hostConfig.port},ssl={TestConfiguration.TLS}"; + connectionString += $",readFrom={strategyString}"; + if (expectedAz != null) + { + connectionString += $",az={expectedAz}"; + } + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Parse the original configuration to verify ReadFrom was set correctly + ConfigurationOptions parsedConfig = ConfigurationOptions.Parse(connectionString); + Assert.NotNull(parsedConfig.ReadFrom); + Assert.Equal(expectedStrategy, parsedConfig.ReadFrom.Value.Strategy); + Assert.Equal(expectedAz, parsedConfig.ReadFrom.Value.Az); + + // Verify the configuration reaches the underlying client by testing functionality + IDatabase database = connectionMultiplexer.GetDatabase(); + Assert.NotNull(database); + + // Test a basic operation to ensure the connection works with ReadFrom configuration + await database.PingAsync(); + + // Test data operations to verify the ReadFrom configuration is active + string testKey = Guid.NewGuid().ToString(); + string testValue = useStandalone ? "standalone-end-to-end-test" : "cluster-end-to-end-test"; + await database.StringSetAsync(testKey, testValue); + string? retrievedValue = await database.StringGetAsync(testKey); + Assert.Equal(testValue, retrievedValue); + } + } + + [Theory] + [InlineData(true)] // Connection string without ReadFrom + [InlineData(false)] // ConfigurationOptions with null ReadFrom + public async Task NullReadFrom_DefaultsToNullInFFILayer(bool useConnectionString) + { + // Arrange & Act + if (useConnectionString) + { + string connectionString = $"{TestConfiguration.STANDALONE_HOSTS[0].host}:{TestConfiguration.STANDALONE_HOSTS[0].port},ssl={TestConfiguration.TLS}"; + + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionString)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Parse the original configuration to verify ReadFrom is null (default behavior) + ConfigurationOptions parsedConfig = ConfigurationOptions.Parse(connectionString); + Assert.Null(parsedConfig.ReadFrom); + + // Test a basic operation to ensure the connection works without ReadFrom configuration + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + + // Test data operations to verify default behavior works + string testKey = Guid.NewGuid().ToString(); + string testValue = "connection-string-default-behavior-test"; + await database.StringSetAsync(testKey, testValue); + string? retrievedValue = await database.StringGetAsync(testKey); + Assert.Equal(testValue, retrievedValue); + + // Cleanup + await database.KeyDeleteAsync(testKey); + } + } + else + { + ConfigurationOptions configOptions = new ConfigurationOptions + { + ReadFrom = null + }; + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Verify ReadFrom is null (default behavior) + Assert.Null(configOptions.ReadFrom); + + // Test a basic operation to ensure the connection works without ReadFrom configuration + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + + // Test data operations to verify default behavior works + string testKey = Guid.NewGuid().ToString(); + string testValue = "config-options-null-readfrom-test"; + await database.StringSetAsync(testKey, testValue); + string? retrievedValue = await database.StringGetAsync(testKey); + Assert.Equal(testValue, retrievedValue); + + // Cleanup + await database.KeyDeleteAsync(testKey); + } + } + } + + #endregion + + #region Configuration Pipeline Validation Tests + + [Fact] + public async Task ConfigurationPipeline_ValidationErrorPropagation() + { + // Test that validation errors propagate correctly through the entire configuration pipeline + + // Arrange: Create configuration with invalid ReadFrom combination + ConfigurationOptions configOptions = new ConfigurationOptions(); + configOptions.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + configOptions.Ssl = TestConfiguration.TLS; + + // Act & Assert: Test invalid assignment through property setter + Assert.Throws(() => + { + configOptions.ReadFrom = new ReadFrom(ReadFromStrategy.Primary, "invalid-az-for-primary"); + }); + + // Test that the configuration remains in a valid state after failed assignment + configOptions.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + using ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configOptions); + Assert.NotNull(connectionMultiplexer); + + // Test basic functionality + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + } + + [Fact] + public async Task ConfigurationPipeline_ClonePreservesReadFromConfiguration() + { + // Arrange + ConfigurationOptions originalConfig = new ConfigurationOptions + { + ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1a") + }; + originalConfig.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + originalConfig.Ssl = TestConfiguration.TLS; + + // Act + ConfigurationOptions clonedConfig = originalConfig.Clone(); + + // Modify original to ensure independence + originalConfig.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + + // Connect using cloned configuration + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(clonedConfig)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Verify cloned configuration preserved the original ReadFrom settings + Assert.NotNull(clonedConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, clonedConfig.ReadFrom.Value.Strategy); + Assert.Equal("us-east-1a", clonedConfig.ReadFrom.Value.Az); + + // Verify original configuration was modified independently + Assert.NotNull(originalConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.Primary, originalConfig.ReadFrom.Value.Strategy); + + // Test basic functionality + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + } + } + + #endregion + + #region Backward Compatibility Tests + + [Theory] + [InlineData(true)] // ConfigurationOptions without ReadFrom + [InlineData(false)] // Connection string without ReadFrom + public async Task BackwardCompatibility_LegacyConfigurationWithoutReadFromStillWorks(bool useConfigurationOptions) + { + // Test that existing applications that don't use ReadFrom continue to work + + if (useConfigurationOptions) + { + // Arrange: Create a legacy-style configuration without ReadFrom + ConfigurationOptions legacyConfig = new ConfigurationOptions(); + legacyConfig.EndPoints.Add(TestConfiguration.STANDALONE_HOSTS[0].host, TestConfiguration.STANDALONE_HOSTS[0].port); + legacyConfig.Ssl = TestConfiguration.TLS; + legacyConfig.ResponseTimeout = 5000; + legacyConfig.ConnectTimeout = 5000; + // Explicitly not setting ReadFrom to simulate legacy behavior + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(legacyConfig)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Verify ReadFrom is null (legacy behavior) + Assert.Null(legacyConfig.ReadFrom); + + // Verify full functionality + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + + // Test basic operations to ensure legacy behavior works + string testKey = "legacy-config-test-key"; + string testValue = "legacy-config-test-value"; + await database.StringSetAsync(testKey, testValue); + string? retrievedValue = await database.StringGetAsync(testKey); + Assert.Equal(testValue, retrievedValue); + + // Cleanup + await database.KeyDeleteAsync(testKey); + } + } + else + { + // Arrange: Create a legacy-style connection string without ReadFrom + string legacyConnectionString = $"{TestConfiguration.STANDALONE_HOSTS[0].host}:{TestConfiguration.STANDALONE_HOSTS[0].port},ssl={TestConfiguration.TLS},connectTimeout=5000,responseTimeout=5000"; + + // Act + using (ConnectionMultiplexer connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(legacyConnectionString)) + { + // Assert - Verify connection was created successfully + Assert.NotNull(connectionMultiplexer); + + // Parse the connection string to verify ReadFrom is null (legacy behavior) + ConfigurationOptions parsedConfig = ConfigurationOptions.Parse(legacyConnectionString); + Assert.Null(parsedConfig.ReadFrom); + + // Verify full functionality + IDatabase database = connectionMultiplexer.GetDatabase(); + await database.PingAsync(); + + // Test basic operations to ensure legacy behavior works + string testKey = "legacy-connection-string-test-key"; + string testValue = "legacy-connection-string-test-value"; + await database.StringSetAsync(testKey, testValue); + string? retrievedValue = await database.StringGetAsync(testKey); + Assert.Equal(testValue, retrievedValue); + + // Cleanup + await database.KeyDeleteAsync(testKey); + } + } + } + + #endregion +} diff --git a/tests/Valkey.Glide.IntegrationTests/TestConfiguration.cs b/tests/Valkey.Glide.IntegrationTests/TestConfiguration.cs index c396c6f..0b57f0f 100644 --- a/tests/Valkey.Glide.IntegrationTests/TestConfiguration.cs +++ b/tests/Valkey.Glide.IntegrationTests/TestConfiguration.cs @@ -302,7 +302,7 @@ private static void TestConsoleWriteLine(string message) => internal List<(string host, ushort port)> StartServer(bool cluster, bool tls = false, string? name = null) { - string cmd = $"start {(cluster ? "--cluster-mode" : "")} {(tls ? " --tls" : "")} {(name != null ? " --prefix " + name : "")}"; + string cmd = $"start {(cluster ? "--cluster-mode" : "")} {(tls ? " --tls" : "")} {(name != null ? " --prefix " + name : "")} -r 3"; return ParseHostsFromOutput(RunClusterManager(cmd, false)); } diff --git a/tests/Valkey.Glide.UnitTests/ConnectionMultiplexerReadFromMappingTests.cs b/tests/Valkey.Glide.UnitTests/ConnectionMultiplexerReadFromMappingTests.cs new file mode 100644 index 0000000..c6a1c98 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/ConnectionMultiplexerReadFromMappingTests.cs @@ -0,0 +1,274 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Xunit; + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.UnitTests; + +public class ConnectionMultiplexerReadFromMappingTests +{ + [Fact] + public void CreateClientConfigBuilder_WithReadFromPrimary_MapsCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + Assert.Equal(ReadFromStrategy.Primary, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Null(standaloneConfig.Request.ReadFrom!.Value.Az); + + Assert.Equal(ReadFromStrategy.Primary, clusterConfig.Request.ReadFrom!.Value.Strategy); + Assert.Null(clusterConfig.Request.ReadFrom!.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_WithReadFromPreferReplica_MapsCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.PreferReplica); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + Assert.Equal(ReadFromStrategy.PreferReplica, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Null(standaloneConfig.Request.ReadFrom!.Value.Az); + + Assert.Equal(ReadFromStrategy.PreferReplica, clusterConfig.Request.ReadFrom!.Value.Strategy); + Assert.Null(clusterConfig.Request.ReadFrom!.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_WithReadFromAzAffinity_MapsCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1a"); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + Assert.Equal(ReadFromStrategy.AzAffinity, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal("us-east-1a", standaloneConfig.Request.ReadFrom!.Value.Az); + + Assert.Equal(ReadFromStrategy.AzAffinity, clusterConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal("us-east-1a", clusterConfig.Request.ReadFrom!.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_WithReadFromAzAffinityReplicasAndPrimary_MapsCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1b"); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal("eu-west-1b", standaloneConfig.Request.ReadFrom!.Value.Az); + + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, clusterConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal("eu-west-1b", clusterConfig.Request.ReadFrom!.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_WithNullReadFrom_HandlesCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = null; + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + Assert.Null(standaloneConfig.Request.ReadFrom); + Assert.Null(clusterConfig.Request.ReadFrom); + } + + [Fact] + public void CreateClientConfigBuilder_ReadFromFlowsToConnectionConfig() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "ap-south-1"); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + + // Assert - Verify ReadFrom flows through to ConnectionConfig + ConnectionConfig connectionConfig = standaloneConfig.ToRequest(); + Assert.NotNull(connectionConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, connectionConfig.ReadFrom.Value.Strategy); + Assert.Equal("ap-south-1", connectionConfig.ReadFrom.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_ReadFromFlowsToFfiLayer() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.PreferReplica); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + + // Assert - Verify ReadFrom flows through to FFI layer + ConnectionConfig connectionConfig = standaloneConfig.ToRequest(); + + // We can't directly access the FFI structure, but we can verify it's properly set in the ConnectionConfig + // The ToFfi() method will properly marshal the ReadFrom to the FFI layer + using var ffiConfig = connectionConfig.ToFfi(); + + // The fact that ToFfi() doesn't throw and the connectionConfig has the correct ReadFrom + // indicates that the mapping is working correctly + Assert.NotNull(connectionConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.PreferReplica, connectionConfig.ReadFrom.Value.Strategy); + Assert.Null(connectionConfig.ReadFrom.Value.Az); + } + + [Theory] + [InlineData(ReadFromStrategy.Primary, null)] + [InlineData(ReadFromStrategy.PreferReplica, null)] + [InlineData(ReadFromStrategy.AzAffinity, "us-west-2")] + [InlineData(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-central-1")] + public void CreateClientConfigBuilder_AllReadFromStrategies_MapCorrectly(ReadFromStrategy strategy, string? az) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = az != null ? new ReadFrom(strategy, az) : new ReadFrom(strategy); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + ClusterClientConfigurationBuilder clusterBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + // Verify standalone configuration + Assert.Equal(strategy, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal(az, standaloneConfig.Request.ReadFrom!.Value.Az); + + // Verify cluster configuration + Assert.Equal(strategy, clusterConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal(az, clusterConfig.Request.ReadFrom!.Value.Az); + } + + [Fact] + public void CreateClientConfigBuilder_WithComplexConfiguration_MapsReadFromCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.EndPoints.Add("localhost:6379"); + options.Ssl = true; + options.User = "testuser"; + options.Password = "testpass"; + options.ClientName = "TestClient"; + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1a"); + + // Act + StandaloneClientConfigurationBuilder standaloneBuilder = ConnectionMultiplexer.CreateClientConfigBuilder(options); + + // Assert + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + + // Verify ReadFrom is correctly mapped alongside other configuration + Assert.Equal(ReadFromStrategy.AzAffinity, standaloneConfig.Request.ReadFrom!.Value.Strategy); + Assert.Equal("us-east-1a", standaloneConfig.Request.ReadFrom!.Value.Az); + + // Verify other configuration is also correctly mapped (basic checks) + Assert.NotNull(standaloneConfig.Request.TlsMode); + Assert.NotNull(standaloneConfig.Request.AuthenticationInfo); + Assert.Equal("TestClient", standaloneConfig.Request.ClientName); + } + + [Fact] + public void ClientConfigurationBuilder_ReadFromConfiguration_FlowsToConnectionConfig() + { + // Arrange + const string testAz = "us-west-2b"; + ReadFrom readFromConfig = new ReadFrom(ReadFromStrategy.AzAffinity, testAz); + + // Act - Test Standalone Configuration + StandaloneClientConfigurationBuilder standaloneBuilder = new StandaloneClientConfigurationBuilder() + .WithAddress("localhost", 6379) + .WithReadFrom(readFromConfig); + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + + // Assert - Standalone + Assert.NotNull(standaloneConfig); + ConnectionConfig standaloneConnectionConfig = standaloneConfig.ToRequest(); + Assert.NotNull(standaloneConnectionConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, standaloneConnectionConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, standaloneConnectionConfig.ReadFrom.Value.Az); + + // Act - Test Cluster Configuration + ClusterClientConfigurationBuilder clusterBuilder = new ClusterClientConfigurationBuilder() + .WithAddress("localhost", 6379) + .WithReadFrom(readFromConfig); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + // Assert - Cluster + Assert.NotNull(clusterConfig); + ConnectionConfig clusterConnectionConfig = clusterConfig.ToRequest(); + Assert.NotNull(clusterConnectionConfig.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, clusterConnectionConfig.ReadFrom.Value.Strategy); + Assert.Equal(testAz, clusterConnectionConfig.ReadFrom.Value.Az); + } + + [Fact] + public void ClientConfigurationBuilder_NullReadFrom_FlowsToConnectionConfig() + { + // Act - Test Standalone Configuration + StandaloneClientConfigurationBuilder standaloneBuilder = new StandaloneClientConfigurationBuilder() + .WithAddress("localhost", 6379); + StandaloneClientConfiguration standaloneConfig = standaloneBuilder.Build(); + + // Assert - Standalone + Assert.NotNull(standaloneConfig); + ConnectionConfig standaloneConnectionConfig = standaloneConfig.ToRequest(); + Assert.Null(standaloneConnectionConfig.ReadFrom); + + // Act - Test Cluster Configuration + ClusterClientConfigurationBuilder clusterBuilder = new ClusterClientConfigurationBuilder() + .WithAddress("localhost", 6379); + ClusterClientConfiguration clusterConfig = clusterBuilder.Build(); + + // Assert - Cluster + Assert.NotNull(clusterConfig); + ConnectionConfig clusterConnectionConfig = clusterConfig.ToRequest(); + Assert.Null(clusterConnectionConfig.ReadFrom); + } +} diff --git a/tests/Valkey.Glide.UnitTests/ReadFromTests.cs b/tests/Valkey.Glide.UnitTests/ReadFromTests.cs new file mode 100644 index 0000000..809aa78 --- /dev/null +++ b/tests/Valkey.Glide.UnitTests/ReadFromTests.cs @@ -0,0 +1,739 @@ +// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 + +using Xunit; + +using static Valkey.Glide.ConnectionConfiguration; + +namespace Valkey.Glide.UnitTests; + +public class ReadFromTests +{ + [Theory] + [InlineData("readFrom=Primary", ReadFromStrategy.Primary, null)] + [InlineData("readFrom=PreferReplica", ReadFromStrategy.PreferReplica, null)] + [InlineData("readFrom=AzAffinity,Az=us-east-1", ReadFromStrategy.AzAffinity, "us-east-1")] + [InlineData("readFrom=AzAffinityReplicasAndPrimary,Az=us-east-1", ReadFromStrategy.AzAffinityReplicasAndPrimary, "us-east-1")] + [InlineData("readFrom=primary", ReadFromStrategy.Primary, null)] + [InlineData("readFrom=azaffinity,Az=us-east-1", ReadFromStrategy.AzAffinity, "us-east-1")] + [InlineData("READFrom=PRIMARY", ReadFromStrategy.Primary, null)] + [InlineData("READFrom=AZAFFINITY,AZ=us-east-1", ReadFromStrategy.AzAffinity, "us-east-1")] + public void Parse_ValidReadFromWithoutAz_SetsCorrectStrategy(string connectionString, ReadFromStrategy expectedStrategy, string? expectedAz) + { + // Act + ConfigurationOptions options = ConfigurationOptions.Parse(connectionString); + + // Assert + Assert.NotNull(options.ReadFrom); + Assert.Equal(expectedStrategy, options.ReadFrom.Value.Strategy); + Assert.Equal(expectedAz, options.ReadFrom.Value.Az); + } + + [Theory] + [InlineData("readFrom=")] + [InlineData("readFrom= ")] + [InlineData("readFrom=\t")] + public void Parse_EmptyReadFromValue_ThrowsArgumentException(string connectionString) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => ConfigurationOptions.Parse(connectionString)); + Assert.Contains("ReadFrom strategy cannot be empty", exception.Message); + } + + [Fact] + public void Parse_InvalidReadFromStrategy_ThrowsArgumentException() + { + // Arrange + string connectionString = "readFrom=InvalidStrategy"; + + // Act & Assert + ArgumentException exception = Assert.Throws(() => ConfigurationOptions.Parse(connectionString)); + Assert.Contains("is not supported", exception.Message); + Assert.Contains("Primary, PreferReplica, AzAffinity, AzAffinityReplicasAndPrimary", exception.Message); + } + + [Theory] + [InlineData("readFrom=AzAffinity")] + [InlineData("readFrom=AzAffinityReplicasAndPrimary")] + public void Parse_AzAffinityStrategiesWithoutAz_ThrowsArgumentException(string connectionString) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => ConfigurationOptions.Parse(connectionString)); + Assert.Contains("Availability zone cannot be empty or whitespace", exception.Message); + } + + [Theory] + [InlineData("az=")] + [InlineData("az= ")] + [InlineData("az=\t")] + public void Parse_EmptyAzValue_ThrowsArgumentException(string connectionString) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => ConfigurationOptions.Parse(connectionString)); + Assert.Contains("cannot be empty or whitespace", exception.Message.ToLower()); + } + + [Fact] + public void ReadFromProperty_SetValidConfiguration_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1"); + + // Act & Assert + options.ReadFrom = readFrom; + Assert.Equal(ReadFromStrategy.AzAffinity, options.ReadFrom.Value.Strategy); + Assert.Equal("us-east-1", options.ReadFrom.Value.Az); + } + + [Fact] + public void ReadFromProperty_SetAzAffinityWithoutAz_ThrowsArgumentException() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + { + // This should throw because ReadFrom constructor validates AZ requirement + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.AzAffinity); + options.ReadFrom = readFrom; + }); + Assert.Contains("Availability zone should be set", exception.Message); + } + + [Fact] + public void ReadFromProperty_SetPrimaryWithAz_ThrowsArgumentException() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + { + // This should throw because ReadFrom constructor validates AZ requirement + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.Primary, "us-east-1"); + options.ReadFrom = readFrom; + }); + Assert.Contains("could be set only when using", exception.Message); + } + + [Fact] + public void Parse_ComplexConnectionStringWithReadFrom_ParsesAllParameters() + { + // Arrange + string connectionString = "localhost:6379,readFrom=AzAffinity,az=us-east-1,ssl=true,user=testuser,password=testpass"; + + // Act + ConfigurationOptions options = ConfigurationOptions.Parse(connectionString); + + // Assert + Assert.NotNull(options.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, options.ReadFrom.Value.Strategy); + Assert.Equal("us-east-1", options.ReadFrom.Value.Az); + Assert.True(options.Ssl); + Assert.Equal("testuser", options.User); + Assert.Equal("testpass", options.Password); + Assert.Single(options.EndPoints); + } + + [Fact] + public void Clone_WithReadFromSet_ClonesReadFromCorrectly() + { + // Arrange + ConfigurationOptions original = ConfigurationOptions.Parse("readFrom=AzAffinity,az=us-east-1"); + + // Act + ConfigurationOptions cloned = original.Clone(); + + // Assert + Assert.NotNull(cloned.ReadFrom); + Assert.Equal(original.ReadFrom.Value.Strategy, cloned.ReadFrom.Value.Strategy); + Assert.Equal(original.ReadFrom.Value.Az, cloned.ReadFrom.Value.Az); + } + + [Fact] + public void ToString_WithReadFromAndAz_IncludesInConnectionString() + { + // Arrange + ConfigurationOptions options = ConfigurationOptions.Parse("localhost:6379,readFrom=AzAffinity,az=us-east-1"); + + // Act + string connectionString = options.ToString(); + + // Assert + Assert.Contains("readFrom=AzAffinity", connectionString); + Assert.Contains("az=us-east-1", connectionString); + } + + [Fact] + public void ToString_WithReadFromWithoutAz_IncludesOnlyReadFrom() + { + // Arrange + ConfigurationOptions options = ConfigurationOptions.Parse("localhost:6379,readFrom=Primary"); + + // Act + string connectionString = options.ToString(); + + // Assert + Assert.Contains("readFrom=Primary", connectionString); + Assert.DoesNotContain("az=", connectionString); + } + + [Fact] + public void ReadFromProperty_SetNull_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert - Setting to null should not throw + options.ReadFrom = null; + Assert.Null(options.ReadFrom); + } + + [Fact] + public void Clone_WithNullReadFrom_ClonesCorrectly() + { + // Arrange + ConfigurationOptions original = new ConfigurationOptions(); + original.ReadFrom = null; + + // Act + ConfigurationOptions cloned = original.Clone(); + + // Assert + Assert.Null(cloned.ReadFrom); + } + + [Theory] + [InlineData("readFrom=AzAffinity,az=")] + [InlineData("readFrom=AzAffinityReplicasAndPrimary,az= ")] + public void Parse_AzAffinityWithEmptyOrWhitespaceAz_ThrowsSpecificException(string connectionString) + { + // Act & Assert + ArgumentException exception = Assert.Throws(() => ConfigurationOptions.Parse(connectionString)); + Assert.Contains("Availability zone cannot be empty or whitespace", exception.Message); + } + + [Theory] + [InlineData(ReadFromStrategy.Primary, "readFrom=Primary")] + [InlineData(ReadFromStrategy.PreferReplica, "readFrom=PreferReplica")] + public void ToString_WithReadFromStrategyWithoutAz_IncludesCorrectFormat(ReadFromStrategy strategy, string expectedSubstring) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(strategy); + + // Act + string result = options.ToString(); + + // Assert + Assert.Contains(expectedSubstring, result); + } + + [Theory] + [InlineData(ReadFromStrategy.AzAffinity, "us-east-1a", "readFrom=AzAffinity,az=us-east-1a")] + [InlineData(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1b", "readFrom=AzAffinityReplicasAndPrimary,az=eu-west-1b")] + public void ToString_WithReadFromStrategyWithAz_IncludesCorrectFormat(ReadFromStrategy strategy, string az, string expectedSubstring) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(strategy, az); + + // Act + string result = options.ToString(); + + // Assert + Assert.Contains(expectedSubstring, result); + } + + [Theory] + [InlineData("us-east-1a")] + [InlineData("eu-west-1b")] + public void ToString_WithAzAffinityStrategy_IncludesCorrectAzFormat(string azValue) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, azValue); + + // Act + string result = options.ToString(); + + // Assert + Assert.Contains("readFrom=AzAffinity", result); + Assert.Contains($"az={azValue}", result); + } + + [Theory] + [InlineData("us-west-2a")] + [InlineData("eu-central-1b")] + public void ToString_WithAzAffinityReplicasAndPrimaryStrategy_IncludesCorrectAzFormat(string azValue) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, azValue); + + // Act + string result = options.ToString(); + + // Assert + Assert.Contains("readFrom=AzAffinityReplicasAndPrimary", result); + Assert.Contains($"az={azValue}", result); + } + + [Fact] + public void ToString_WithNullReadFrom_DoesNotIncludeReadFromOrAz() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = null; + + // Act + string result = options.ToString(); + + // Assert + Assert.DoesNotContain("readFrom=", result); + Assert.DoesNotContain("az=", result); + } + + [Fact] + public void ToString_WithComplexConfiguration_IncludesAllParameters() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.EndPoints.Add("localhost:6379"); + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1a"); + options.Ssl = true; + options.User = "testuser"; + options.Password = "testpass"; + + // Act + string result = options.ToString(); + + // Assert + Assert.Contains("localhost:6379", result); + Assert.Contains("readFrom=AzAffinity", result); + Assert.Contains("az=us-east-1a", result); + Assert.Contains("ssl=True", result); + Assert.Contains("user=testuser", result); + Assert.Contains("password=testpass", result); + } + + [Theory] + [InlineData("readFrom=Primary")] + [InlineData("readFrom=PreferReplica")] + [InlineData("readFrom=AzAffinity,az=us-east-1")] + [InlineData("readFrom=AzAffinityReplicasAndPrimary,az=eu-west-1")] + public void RoundTrip_ParseToStringToParse_PreservesReadFromConfiguration(string originalConnectionString) + { + // Act - First parse + ConfigurationOptions options1 = ConfigurationOptions.Parse(originalConnectionString); + + // Act - ToString + string serialized = options1.ToString(); + + // Act - Second parse + ConfigurationOptions options2 = ConfigurationOptions.Parse(serialized); + + // Assert + Assert.Equal(options1.ReadFrom?.Strategy, options2.ReadFrom?.Strategy); + Assert.Equal(options1.ReadFrom?.Az, options2.ReadFrom?.Az); + } + + [Fact] + public void RoundTrip_ComplexConfigurationWithReadFrom_PreservesAllSettings() + { + // Arrange + string originalConnectionString = "localhost:6379,readFrom=AzAffinity,az=us-east-1,ssl=true,user=testuser"; + + // Act - First parse + ConfigurationOptions options1 = ConfigurationOptions.Parse(originalConnectionString); + + // Act - ToString + string serialized = options1.ToString(); + + // Act - Second parse + ConfigurationOptions options2 = ConfigurationOptions.Parse(serialized); + + // Assert ReadFrom configuration + Assert.Equal(options1.ReadFrom?.Strategy, options2.ReadFrom?.Strategy); + Assert.Equal(options1.ReadFrom?.Az, options2.ReadFrom?.Az); + + // Assert other configuration is preserved + Assert.Equal(options1.Ssl, options2.Ssl); + Assert.Equal(options1.User, options2.User); + Assert.Equal(options1.EndPoints.Count, options2.EndPoints.Count); + } + + [Fact] + public void RoundTrip_ProgrammaticallySetReadFrom_PreservesConfiguration() + { + // Arrange + ConfigurationOptions options1 = new ConfigurationOptions(); + options1.EndPoints.Add("localhost:6379"); + options1.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, "ap-south-1"); + + // Act - ToString + string serialized = options1.ToString(); + + // Act - Parse + ConfigurationOptions options2 = ConfigurationOptions.Parse(serialized); + + // Assert + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, options2.ReadFrom?.Strategy); + Assert.Equal("ap-south-1", options2.ReadFrom?.Az); + } + + [Fact] + public void ToString_ExistingConfigurationWithoutReadFrom_RemainsUnchanged() + { + // Arrange + ConfigurationOptions options = ConfigurationOptions.Parse("localhost:6379,ssl=true,user=testuser"); + + // Act + string result = options.ToString(); + + // Assert - Should not contain ReadFrom parameters + Assert.DoesNotContain("readFrom=", result); + Assert.DoesNotContain("az=", result); + + // Assert - Should contain existing parameters + Assert.Contains("localhost:6379", result); + Assert.Contains("ssl=True", result); + Assert.Contains("user=testuser", result); + } + + [Fact] + public void ToString_DefaultConfigurationOptions_DoesNotIncludeReadFrom() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act + string result = options.ToString(); + + // Assert + Assert.DoesNotContain("readFrom=", result); + Assert.DoesNotContain("az=", result); + } + + [Fact] + public void RoundTrip_LegacyConnectionString_RemainsCompatible() + { + // Arrange - Legacy connection string without ReadFrom + string legacyConnectionString = "localhost:6379,ssl=true,connectTimeout=5000,user=admin,password=secret"; + + // Act - Parse and serialize + ConfigurationOptions options = ConfigurationOptions.Parse(legacyConnectionString); + string serialized = options.ToString(); + ConfigurationOptions reparsed = ConfigurationOptions.Parse(serialized); + + // Assert - ReadFrom should be null (default behavior) + Assert.Null(options.ReadFrom); + Assert.Null(reparsed.ReadFrom); + + // Assert - Other settings preserved + Assert.Equal(options.Ssl, reparsed.Ssl); + Assert.Equal(options.User, reparsed.User); + Assert.Equal(options.Password, reparsed.Password); + } + + [Fact] + public void ReadFromProperty_SetValidPrimaryStrategy_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.Primary); + + // Act & Assert + options.ReadFrom = readFrom; + Assert.Equal(ReadFromStrategy.Primary, options.ReadFrom.Value.Strategy); + Assert.Null(options.ReadFrom.Value.Az); + } + + [Fact] + public void ReadFromProperty_SetValidPreferReplicaStrategy_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.PreferReplica); + + // Act & Assert + options.ReadFrom = readFrom; + Assert.Equal(ReadFromStrategy.PreferReplica, options.ReadFrom.Value.Strategy); + Assert.Null(options.ReadFrom.Value.Az); + } + + [Fact] + public void ReadFromProperty_SetValidAzAffinityStrategy_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-east-1"); + + // Act & Assert + options.ReadFrom = readFrom; + Assert.Equal(ReadFromStrategy.AzAffinity, options.ReadFrom.Value.Strategy); + Assert.Equal("us-east-1", options.ReadFrom.Value.Az); + } + + [Fact] + public void ReadFromProperty_SetValidAzAffinityReplicasAndPrimaryStrategy_DoesNotThrow() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + ReadFrom readFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1"); + + // Act & Assert + options.ReadFrom = readFrom; + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, options.ReadFrom.Value.Strategy); + Assert.Equal("eu-west-1", options.ReadFrom.Value.Az); + } + + + + [Fact] + public void ReadFromProperty_SetMultipleTimes_UpdatesCorrectly() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert - Set Primary first + options.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + Assert.Equal(ReadFromStrategy.Primary, options.ReadFrom.Value.Strategy); + Assert.Null(options.ReadFrom.Value.Az); + + // Act & Assert - Change to AzAffinity + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-west-2"); + Assert.Equal(ReadFromStrategy.AzAffinity, options.ReadFrom.Value.Strategy); + Assert.Equal("us-west-2", options.ReadFrom.Value.Az); + + // Act & Assert - Change back to null + options.ReadFrom = null; + Assert.Null(options.ReadFrom); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + public void ReadFromProperty_SetAzAffinityWithEmptyOrWhitespaceAz_ThrowsArgumentException(string azValue) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + { + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, azValue); + }); + Assert.Contains("Availability zone cannot be empty or whitespace", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + public void ReadFromProperty_SetAzAffinityReplicasAndPrimaryWithEmptyOrWhitespaceAz_ThrowsArgumentException(string azValue) + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + { + options.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, azValue); + }); + Assert.Contains("Availability zone cannot be empty or whitespace", exception.Message); + } + + [Fact] + public void Clone_ModifyingClonedReadFrom_DoesNotAffectOriginal() + { + // Arrange + ConfigurationOptions original = new ConfigurationOptions(); + original.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + + // Act + ConfigurationOptions cloned = original.Clone(); + cloned.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinity, "us-west-2"); + + // Assert - Original should remain unchanged + Assert.NotNull(original.ReadFrom); + Assert.Equal(ReadFromStrategy.Primary, original.ReadFrom.Value.Strategy); + Assert.Null(original.ReadFrom.Value.Az); + + // Assert - Cloned should have new value + Assert.NotNull(cloned.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinity, cloned.ReadFrom.Value.Strategy); + Assert.Equal("us-west-2", cloned.ReadFrom.Value.Az); + } + + [Fact] + public void Clone_WithComplexConfigurationIncludingReadFrom_PreservesAllSettings() + { + // Arrange + ConfigurationOptions original = new ConfigurationOptions(); + original.EndPoints.Add("localhost:6379"); + original.ReadFrom = new ReadFrom(ReadFromStrategy.AzAffinityReplicasAndPrimary, "ap-south-1"); + original.Ssl = true; + original.User = "testuser"; + original.Password = "testpass"; + original.ConnectTimeout = 5000; + original.ResponseTimeout = 3000; + + // Act + ConfigurationOptions cloned = original.Clone(); + + // Assert ReadFrom configuration + Assert.NotNull(cloned.ReadFrom); + Assert.Equal(ReadFromStrategy.AzAffinityReplicasAndPrimary, cloned.ReadFrom.Value.Strategy); + Assert.Equal("ap-south-1", cloned.ReadFrom.Value.Az); + + // Assert other configuration is preserved + Assert.Equal(original.Ssl, cloned.Ssl); + Assert.Equal(original.User, cloned.User); + Assert.Equal(original.Password, cloned.Password); + Assert.Equal(original.ConnectTimeout, cloned.ConnectTimeout); + Assert.Equal(original.ResponseTimeout, cloned.ResponseTimeout); + Assert.Equal(original.EndPoints.Count, cloned.EndPoints.Count); + } + + [Fact] + public void ReadFromProperty_DefaultValue_IsNull() + { + // Arrange & Act + ConfigurationOptions options = new ConfigurationOptions(); + + // Assert + Assert.Null(options.ReadFrom); + } + + [Fact] + public void ReadFromProperty_AfterSettingToNonNull_CanBeSetBackToNull() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.ReadFrom = new ReadFrom(ReadFromStrategy.Primary); + + // Act + options.ReadFrom = null; + + // Assert + Assert.Null(options.ReadFrom); + } + + [Fact] + public void ReadFromProperty_NullValue_DoesNotAffectToString() + { + // Arrange + ConfigurationOptions options = new ConfigurationOptions(); + options.EndPoints.Add("localhost:6379"); + options.ReadFrom = null; + + // Act + string result = options.ToString(); + + // Assert + Assert.DoesNotContain("readFrom=", result); + Assert.DoesNotContain("az=", result); + Assert.Contains("localhost:6379", result); + } + + [Fact] + public void ReadFromProperty_NullValue_DoesNotAffectClone() + { + // Arrange + ConfigurationOptions original = new ConfigurationOptions(); + original.EndPoints.Add("localhost:6379"); + original.Ssl = true; + original.ReadFrom = null; + + // Act + ConfigurationOptions cloned = original.Clone(); + + // Assert + Assert.Null(cloned.ReadFrom); + Assert.Equal(original.Ssl, cloned.Ssl); + Assert.Equal(original.EndPoints.Count, cloned.EndPoints.Count); + } + + [Theory] + [InlineData("InvalidStrategy", "", "is not supported")] + [InlineData("Unknown", "", "is not supported")] + [InlineData("PrimaryAndSecondary", "", "is not supported")] + [InlineData("", "", "cannot be empty")] + [InlineData("AzAffinity", "", "Availability zone cannot be empty or whitespace")] + [InlineData("AzAffinityReplicasAndPrimary", "", "Availability zone cannot be empty or whitespace")] + [InlineData("AzAffinity", " ", "Availability zone cannot be empty or whitespace")] + [InlineData("AzAffinity", "\t", "Availability zone cannot be empty or whitespace")] + [InlineData("AzAffinity", "\n", "Availability zone cannot be empty or whitespace")] + [InlineData("AzAffinityReplicasAndPrimary", " ", "Availability zone cannot be empty or whitespace")] + public async Task ConnectionString_ArgumentExceptionScenarios(string readFromStrategy, string azValue, string expectedErrorSubstring) + { + // Arrange + string connectionString = $"localhost:6379,readFrom={readFromStrategy}"; + if (!string.IsNullOrEmpty(azValue)) + { + connectionString += $",az={azValue}"; + } + + // Act & Assert + ArgumentException exception = await Assert.ThrowsAsync( + () => ConnectionMultiplexer.ConnectAsync(connectionString)); + + Assert.Contains(expectedErrorSubstring, exception.Message); + } + + [Theory] + [InlineData("primary")] + [InlineData("PREFERREPLICA")] + [InlineData("azaffinity")] + [InlineData("AzAffinityReplicasAndPrimary")] + public async Task ConnectionString_CaseInsensitiveReadFromParsing(string strategyString) + { + // Arrange + ReadFromStrategy expectedStrategy = Enum.Parse(strategyString, ignoreCase: true); + + string connectionString = $"localhost:6379,readFrom={strategyString}"; + if (expectedStrategy is ReadFromStrategy.AzAffinity or ReadFromStrategy.AzAffinityReplicasAndPrimary) + { + connectionString += ",az=test-zone"; + } + + // Parse the original configuration to verify ReadFrom was set correctly + ConfigurationOptions parsedConfig = ConfigurationOptions.Parse(connectionString); + Assert.NotNull(parsedConfig.ReadFrom); + Assert.Equal(expectedStrategy, parsedConfig.ReadFrom.Value.Strategy); + } + + [Theory] + [InlineData(ReadFromStrategy.Primary, null)] + [InlineData(ReadFromStrategy.PreferReplica, null)] + [InlineData(ReadFromStrategy.AzAffinity, "us-east-1a")] + [InlineData(ReadFromStrategy.AzAffinityReplicasAndPrimary, "eu-west-1b")] + [InlineData(null, null)] // Null ReadFrom test case + public async Task RoundTripSerialization_MaintainsConfigurationIntegrity(ReadFromStrategy? strategy, string? az) + { + // Arrange + ConfigurationOptions originalConfig = new ConfigurationOptions(); + originalConfig.EndPoints.Add("localhost"); + + originalConfig.ReadFrom = strategy.HasValue + ? (az != null + ? new ReadFrom(strategy.Value, az) + : new ReadFrom(strategy.Value)) + : null; + + // Act 1: Serialize to string + string serializedConfig = originalConfig.ToString(); + + // Act 2: Parse back from string + ConfigurationOptions parsedConfig = ConfigurationOptions.Parse(serializedConfig); + + // Verify functional equivalence between original and parsed configurations + Assert.Equal(originalConfig.ReadFrom?.Strategy, parsedConfig.ReadFrom?.Strategy); + Assert.Equal(originalConfig.ReadFrom?.Az, parsedConfig.ReadFrom?.Az); + } +}