test: add E2E WebSocket transport tests (connect, pub/sub round-trip)
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
using NATS.Client.Core;
|
||||||
|
|
||||||
|
namespace NATS.E2E.Tests.Infrastructure;
|
||||||
|
|
||||||
|
public sealed class WebSocketServerFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private NatsServerProcess _server = null!;
|
||||||
|
|
||||||
|
public int Port => _server.Port;
|
||||||
|
public int WsPort { get; private set; }
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
WsPort = NatsServerProcess.AllocateFreePort();
|
||||||
|
|
||||||
|
var config = $$"""
|
||||||
|
websocket {
|
||||||
|
listen: 127.0.0.1:{{WsPort}}
|
||||||
|
no_tls: true
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
_server = NatsServerProcess.WithConfig(config);
|
||||||
|
await _server.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _server.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatsConnection CreateNatsClient()
|
||||||
|
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition("E2E-WebSocket")]
|
||||||
|
public class WebSocketCollection : ICollectionFixture<WebSocketServerFixture>;
|
||||||
99
tests/NATS.E2E.Tests/WebSocketTests.cs
Normal file
99
tests/NATS.E2E.Tests/WebSocketTests.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using NATS.E2E.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace NATS.E2E.Tests;
|
||||||
|
|
||||||
|
[Collection("E2E-WebSocket")]
|
||||||
|
public class WebSocketTests(WebSocketServerFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task WebSocket_ConnectAndReceiveInfo()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
using var ws = new ClientWebSocket();
|
||||||
|
|
||||||
|
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token);
|
||||||
|
ws.State.ShouldBe(WebSocketState.Open);
|
||||||
|
|
||||||
|
var buffer = new byte[4096];
|
||||||
|
var result = await ws.ReceiveAsync(buffer, cts.Token);
|
||||||
|
var info = Encoding.ASCII.GetString(buffer, 0, result.Count);
|
||||||
|
info.ShouldStartWith("INFO");
|
||||||
|
|
||||||
|
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WebSocket_PubSub_RoundTrip()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
using var ws = new ClientWebSocket();
|
||||||
|
|
||||||
|
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token);
|
||||||
|
|
||||||
|
var reader = new WsLineReader(ws);
|
||||||
|
|
||||||
|
// Read the initial INFO frame
|
||||||
|
await reader.ReadLineAsync(cts.Token);
|
||||||
|
|
||||||
|
await WsSend(ws, "CONNECT {\"verbose\":false,\"protocol\":1}\r\n", cts.Token);
|
||||||
|
await WsSend(ws, "SUB e2e.ws.test 1\r\n", cts.Token);
|
||||||
|
await WsSend(ws, "PING\r\n", cts.Token);
|
||||||
|
var pong = await reader.ReadLineAsync(cts.Token);
|
||||||
|
pong.ShouldBe("PONG");
|
||||||
|
|
||||||
|
await using var natsClient = fixture.CreateNatsClient();
|
||||||
|
await natsClient.ConnectAsync();
|
||||||
|
await natsClient.PublishAsync("e2e.ws.test", "ws-hello");
|
||||||
|
await natsClient.PingAsync();
|
||||||
|
|
||||||
|
var msgLine = await reader.ReadLineAsync(cts.Token);
|
||||||
|
msgLine.ShouldStartWith("MSG e2e.ws.test 1");
|
||||||
|
|
||||||
|
var payload = await reader.ReadLineAsync(cts.Token);
|
||||||
|
payload.ShouldBe("ws-hello");
|
||||||
|
|
||||||
|
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WsSend(ClientWebSocket ws, string data, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.ASCII.GetBytes(data);
|
||||||
|
await ws.SendAsync(bytes, WebSocketMessageType.Binary, true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffers incoming WebSocket frames and returns one NATS protocol line at a time.
|
||||||
|
/// Handles the case where a single WebSocket frame contains multiple protocol lines
|
||||||
|
/// (e.g., MSG header + payload delivered in one frame).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class WsLineReader(ClientWebSocket ws)
|
||||||
|
{
|
||||||
|
private readonly byte[] _recvBuffer = new byte[4096];
|
||||||
|
private readonly StringBuilder _pending = new();
|
||||||
|
|
||||||
|
public async Task<string> ReadLineAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var full = _pending.ToString();
|
||||||
|
var crlfIdx = full.IndexOf("\r\n", StringComparison.Ordinal);
|
||||||
|
if (crlfIdx >= 0)
|
||||||
|
{
|
||||||
|
var line = full[..crlfIdx];
|
||||||
|
_pending.Clear();
|
||||||
|
_pending.Append(full[(crlfIdx + 2)..]);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await ws.ReceiveAsync(_recvBuffer, ct);
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
throw new InvalidOperationException("WebSocket closed unexpectedly while reading");
|
||||||
|
|
||||||
|
var chunk = Encoding.ASCII.GetString(_recvBuffer, 0, result.Count);
|
||||||
|
_pending.Append(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user