This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 842538572 test(csharp): add leader_redirection scenario to BDD tests
(#3193)
842538572 is described below
commit 842538572f74dca08ad36372102d2856078d3ce1
Author: Anurag <[email protected]>
AuthorDate: Fri May 1 15:20:17 2026 +0530
test(csharp): add leader_redirection scenario to BDD tests (#3193)
---
bdd/docker-compose.yml | 9 +-
.../Iggy_SDK.Tests.BDD/Context/TestContext.cs | 5 +
.../csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs | 19 +-
.../Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj | 9 +-
.../StepDefinitions/LeaderRedirectionSteps.cs | 317 +++++++++++++++++++++
5 files changed, 352 insertions(+), 7 deletions(-)
diff --git a/bdd/docker-compose.yml b/bdd/docker-compose.yml
index c5d39a601..ddb28b9d9 100644
--- a/bdd/docker-compose.yml
+++ b/bdd/docker-compose.yml
@@ -263,11 +263,18 @@ services:
context: ..
dockerfile: bdd/csharp/Dockerfile
depends_on:
- - iggy-server
+ iggy-server:
+ condition: service_healthy
+ iggy-leader:
+ condition: service_healthy
+ iggy-follower:
+ condition: service_healthy
environment:
- IGGY_ROOT_USERNAME=iggy
- IGGY_ROOT_PASSWORD=iggy
- IGGY_TCP_ADDRESS=iggy-server:8090
+ - IGGY_TCP_ADDRESS_LEADER=iggy-leader:8091
+ - IGGY_TCP_ADDRESS_FOLLOWER=iggy-follower:8092
command: [ "dotnet", "test" ]
networks:
- iggy-bdd-network
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
index 2ae5176cc..17b237936 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
@@ -25,8 +25,13 @@ public class TestContext
{
public IIggyClient IggyClient { get; set; } = null!;
public string TcpUrl { get; set; } = string.Empty;
+ public string LeaderTcpUrl { get; set; } = string.Empty;
+ public string FollowerTcpUrl { get; set; } = string.Empty;
+ public Dictionary<string, IIggyClient> Clients { get; } = new();
public StreamResponse? CreatedStream { get; set; }
public TopicResponse? CreatedTopic { get; set; }
public List<MessageResponse> PolledMessages { get; set; } = new();
public Message? LastSendMessage { get; set; }
+ public bool RedirectionOccurred { get; set; }
+ public uint? LastStreamId { get; set; }
}
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
index fa1c4d386..c716a4e9d 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
@@ -33,23 +33,36 @@ public class TestHooks
public void BeforeScenario()
{
_context.TcpUrl =
Environment.GetEnvironmentVariable("IGGY_TCP_ADDRESS") ?? "127.0.0.1:8090";
+ _context.LeaderTcpUrl =
Environment.GetEnvironmentVariable("IGGY_TCP_ADDRESS_LEADER") ??
"127.0.0.1:8091";
+ _context.FollowerTcpUrl =
Environment.GetEnvironmentVariable("IGGY_TCP_ADDRESS_FOLLOWER") ??
"127.0.0.1:8092";
+ _context.Clients.Clear();
+ _context.CreatedStream = null;
+ _context.RedirectionOccurred = false;
+ _context.LastStreamId = null;
}
[AfterScenario]
public void AfterScenario()
{
- //
+ var clients = _context.Clients.Values.Distinct().ToList();
+ if (_context.IggyClient is not null &&
!clients.Contains(_context.IggyClient))
+ {
+ clients.Add(_context.IggyClient);
+ }
+
+ foreach (var client in clients)
+ {
+ try { client.Dispose(); } catch { /* best-effort cleanup */ }
+ }
}
[BeforeFeature]
public static void BeforeFeature()
{
- //
}
[AfterFeature]
public static void AfterFeature()
{
- //
}
}
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
index 74534d0a7..d1375367f 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
@@ -7,6 +7,7 @@
<IsPackable>false</IsPackable>
<AssemblyName>Apache.Iggy.Tests.BDD</AssemblyName>
<RootNamespace>Apache.Iggy.Tests.BDD</RootNamespace>
+
<ReqnrollUseIntermediateOutputPathForCodeBehind>true</ReqnrollUseIntermediateOutputPathForCodeBehind>
</PropertyGroup>
<PropertyGroup>
@@ -34,10 +35,12 @@
</ItemGroup>
<ItemGroup>
- <Content Include="..\..\..\bdd\scenarios\basic_messaging.feature">
+ <ReqnrollFeatureFile
Include="..\..\..\bdd\scenarios\basic_messaging.feature">
<Link>Features\basic_messaging.feature</Link>
- <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
- </Content>
+ </ReqnrollFeatureFile>
+ <ReqnrollFeatureFile
Include="..\..\..\bdd\scenarios\leader_redirection.feature">
+ <Link>Features\leader_redirection.feature</Link>
+ </ReqnrollFeatureFile>
</ItemGroup>
<ItemGroup>
diff --git
a/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/LeaderRedirectionSteps.cs
b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/LeaderRedirectionSteps.cs
new file mode 100644
index 000000000..6987a453f
--- /dev/null
+++
b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/LeaderRedirectionSteps.cs
@@ -0,0 +1,317 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Factory;
+using Apache.Iggy.IggyClient;
+using Reqnroll;
+using Shouldly;
+using TestContext = Apache.Iggy.Tests.BDD.Context.TestContext;
+
+namespace Apache.Iggy.Tests.BDD.StepDefinitions;
+
+[Binding]
+public class LeaderRedirectionSteps
+{
+ private const string RootUsername = "iggy";
+ private const string RootPassword = "iggy";
+
+ private readonly TestContext _context;
+
+ public LeaderRedirectionSteps(TestContext context)
+ {
+ _context = context;
+ }
+
+ // ---------- Background ----------
+
+ [Given(@"I have cluster configuration enabled with (\d+) nodes")]
+ public void GivenIHaveClusterConfigurationEnabledWithNodes(int nodeCount)
+ {
+ nodeCount.ShouldBeGreaterThan(0);
+ }
+
+ [Given(@"node (\d+) is configured on port (\d+)")]
+ public void GivenNodeIsConfiguredOnPort(int nodeId, int port)
+ {
+ ResolveAddressForPort(port).ShouldNotBeNullOrEmpty();
+ }
+
+ // ---------- Server start steps (just validate addresses are configured)
----------
+
+ [Given(@"I start server (\d+) on port (\d+) as (leader|follower)")]
+ public void GivenIStartServerOnPortAs(int nodeId, int port, string role)
+ {
+ var address = ResolveAddressForRole(role);
+ if (!address.EndsWith($":{port}"))
+ {
+ throw new InvalidOperationException(
+ $"{role} address {address} does not match expected port
{port}");
+ }
+ }
+
+ [Given(@"I start a single server on port (\d+) without clustering
enabled")]
+ public void GivenIStartASingleServerOnPortWithoutClusteringEnabled(int
port)
+ {
+ if (!_context.TcpUrl.EndsWith($":{port}"))
+ {
+ throw new InvalidOperationException(
+ $"Single-server address {_context.TcpUrl} does not match
expected port {port}");
+ }
+ }
+
+ // ---------- Client creation ----------
+
+ [When(@"I create a client connecting to (follower|leader) on port (\d+)")]
+ public async Task WhenICreateAClientConnectingToOnPort(string role, int
port)
+ {
+ var address = ResolveAddressForRole(role);
+ if (!address.EndsWith($":{port}"))
+ {
+ throw new InvalidOperationException(
+ $"{role} address {address} does not match expected port
{port}");
+ }
+ await CreateAndConnectClient("main", address);
+ }
+
+ [When(@"I create a client connecting directly to leader on port (\d+)")]
+ public async Task WhenICreateAClientConnectingDirectlyToLeaderOnPort(int
port)
+ {
+ var address = _context.LeaderTcpUrl;
+ if (!address.EndsWith($":{port}"))
+ {
+ throw new InvalidOperationException(
+ $"Leader address {address} does not match expected port
{port}");
+ }
+ await CreateAndConnectClient("main", address);
+ _context.RedirectionOccurred = false;
+ }
+
+ [When(@"I create a client connecting to port (\d+)")]
+ public async Task WhenICreateAClientConnectingToPort(int port)
+ {
+ await CreateAndConnectClient("main", ResolveAddressForPort(port));
+ }
+
+ [When(@"I create client ([A-Z]) connecting to port (\d+)")]
+ public async Task WhenICreateNamedClientConnectingToPort(string
clientName, int port)
+ {
+ await CreateAndConnectClient(clientName, ResolveAddressForPort(port));
+ }
+
+ // ---------- Auth ----------
+
+ [When(@"I authenticate as root user")]
+ public async Task WhenIAuthenticateAsRootUser()
+ {
+ await AuthenticateAllClients();
+ }
+
+ [When(@"both clients authenticate as root user")]
+ public async Task WhenBothClientsAuthenticateAsRootUser()
+ {
+ await AuthenticateAllClients();
+ }
+
+ private async Task AuthenticateAllClients()
+ {
+ var names = _context.Clients.Count > 1
+ ? _context.Clients.Keys.ToList()
+ : new List<string> { "main" };
+
+ foreach (var name in names)
+ {
+ var client = GetClient(name);
+ var initialAddress = client.GetCurrentAddress();
+ var result = await client.LoginUserAsync(RootUsername,
RootPassword);
+ result.ShouldNotBeNull("Failed to login as root");
+
+ // RedirectAsync runs inside LoginUserAsync; if address changed,
redirection happened.
+ if (client.GetCurrentAddress() != initialAddress)
+ {
+ _context.RedirectionOccurred = true;
+ }
+
+ if (_context.Clients.Count > 1)
+ {
+ await Task.Delay(100);
+ }
+ }
+ }
+
+ // ---------- Stream operations ----------
+
+ [When(@"I create a stream named ""(.+)""")]
+ public async Task WhenICreateAStreamNamed(string streamName)
+ {
+ var client = GetClient("main");
+ var stream = await client.CreateStreamAsync(streamName);
+ stream.ShouldNotBeNull("Should be able to create stream");
+ _context.LastStreamId = stream.Id;
+ }
+
+ [Then(@"the stream should be created successfully on the leader")]
+ public void ThenTheStreamShouldBeCreatedSuccessfullyOnTheLeader()
+ {
+ _context.LastStreamId.ShouldNotBeNull("Stream should have been created
on leader");
+ }
+
+ // ---------- Connection / redirection assertions ----------
+
+ [Then(@"the client should automatically redirect to leader on port (\d+)")]
+ public async Task
ThenTheClientShouldAutomaticallyRedirectToLeaderOnPort(int expectedPort)
+ {
+ await VerifyClientPort("main", expectedPort, markRedirection: true);
+ }
+
+ [Then(@"client ([A-Z]) should stay connected to port (\d+)")]
+ public async Task ThenNamedClientShouldStayConnectedToPort(string
clientName, int expectedPort)
+ {
+ await VerifyClientPort(clientName, expectedPort, markRedirection:
false);
+ }
+
+ [Then(@"client ([A-Z]) should redirect to port (\d+)")]
+ public async Task ThenNamedClientShouldRedirectToPort(string clientName,
int expectedPort)
+ {
+ await VerifyClientPort(clientName, expectedPort, markRedirection:
true);
+ }
+
+ [Then(@"the client should not perform any redirection")]
+ public void ThenTheClientShouldNotPerformAnyRedirection()
+ {
+ _context.RedirectionOccurred.ShouldBeFalse(
+ "No redirection should occur when connecting directly to leader");
+ }
+
+ [Then(@"the connection should remain on port (\d+)")]
+ public async Task ThenTheConnectionShouldRemainOnPort(int port)
+ {
+ var client = GetClient("main");
+ await VerifyClientConnection(client, port);
+ _context.RedirectionOccurred.ShouldBeFalse("Connection should not have
been redirected");
+ }
+
+ [Then(@"the client should connect successfully without redirection")]
+ public async Task
ThenTheClientShouldConnectSuccessfullyWithoutRedirection()
+ {
+ var client = GetClient("main");
+ await client.PingAsync();
+ _context.RedirectionOccurred.ShouldBeFalse("No redirection should
occur without clustering");
+ }
+
+ [Then(@"both clients should be using the same server")]
+ public async Task ThenBothClientsShouldBeUsingTheSameServer()
+ {
+ var clientA = GetClient("A");
+ var clientB = GetClient("B");
+
+ clientA.GetCurrentAddress().ShouldBe(clientB.GetCurrentAddress(),
+ "Both clients should be connected to the same server");
+
+ await clientA.PingAsync();
+ await clientB.PingAsync();
+
+ var leaderA = await GetLeaderFromMetadata(clientA);
+ var leaderB = await GetLeaderFromMetadata(clientB);
+ if (leaderA is not null && leaderB is not null)
+ {
+ $"{leaderA.Ip}:{leaderA.Endpoints.Tcp}".ShouldBe(
+ $"{leaderB.Ip}:{leaderB.Endpoints.Tcp}",
+ "Both clients should see the same leader");
+ }
+ }
+
+ // ---------- Helpers ----------
+
+ private string ResolveAddressForRole(string role) =>
role.ToLowerInvariant() switch
+ {
+ "leader" => _context.LeaderTcpUrl,
+ "follower" => _context.FollowerTcpUrl,
+ "single" => _context.TcpUrl,
+ _ => throw new ArgumentOutOfRangeException(nameof(role), role,
"Unknown role"),
+ };
+
+ private string ResolveAddressForPort(int port) => port switch
+ {
+ 8090 => _context.TcpUrl,
+ 8091 => _context.LeaderTcpUrl,
+ 8092 => _context.FollowerTcpUrl,
+ _ => throw new ArgumentOutOfRangeException(nameof(port), port,
"Unknown port"),
+ };
+
+ private async Task CreateAndConnectClient(string name, string address)
+ {
+ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
+ {
+ BaseAddress = address,
+ Protocol = Protocol.Tcp,
+ });
+ await client.ConnectAsync();
+ _context.Clients[name] = client;
+ if (name == "main")
+ {
+ _context.IggyClient = client;
+ }
+ }
+
+ private IIggyClient GetClient(string name)
+ {
+ return _context.Clients.TryGetValue(name, out var c)
+ ? c
+ : throw new InvalidOperationException($"Client {name} should
exist");
+ }
+
+ private async Task VerifyClientPort(string clientName, int expectedPort,
bool markRedirection)
+ {
+ var client = GetClient(clientName);
+ await VerifyClientConnection(client, expectedPort);
+
+ var leader = await GetLeaderFromMetadata(client);
+ if (leader is not null && markRedirection && leader.Endpoints.Tcp ==
expectedPort)
+ {
+ _context.RedirectionOccurred = true;
+ }
+ }
+
+ private static async Task VerifyClientConnection(IIggyClient client, int
expectedPort)
+ {
+ var addr = client.GetCurrentAddress();
+ if (!addr.EndsWith($":{expectedPort}"))
+ {
+ throw new ShouldAssertException(
+ $"Expected connection to port {expectedPort}, but connected
to: {addr}");
+ }
+ await client.PingAsync();
+ }
+
+ private static async Task<ClusterNode?> GetLeaderFromMetadata(IIggyClient
client)
+ {
+ try
+ {
+ var metadata = await client.GetClusterMetadataAsync();
+ return metadata?.Nodes.FirstOrDefault(n =>
+ n.Role == ClusterNodeRole.Leader && n.Status ==
ClusterNodeStatus.Healthy);
+ }
+ catch (IggyInvalidStatusCodeException e) when (e.StatusCode == 5)
+ {
+ return null;
+ }
+ }
+}