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;
+        }
+    }
+}

Reply via email to