feat: add suitelink client runtime and test harness
This commit is contained in:
209
tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs
Normal file
209
tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
using SuiteLink.Client.Transport;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkClientConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAsync_SendsHandshakeThenConnect_ButDoesNotReportReadyYet()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var transport = new FakeTransport([handshakeAckFrame[..4], handshakeAckFrame[4..]]);
|
||||
var client = new SuiteLinkClient(transport);
|
||||
var options = CreateOptions();
|
||||
|
||||
await client.ConnectAsync(options);
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.Equal(2, transport.SentBuffers.Count);
|
||||
Assert.Equal(
|
||||
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
||||
options.Application,
|
||||
options.ClientNode,
|
||||
options.UserName),
|
||||
transport.SentBuffers[0]);
|
||||
Assert.Equal(SuiteLinkConnectCodec.Encode(options), transport.SentBuffers[1]);
|
||||
Assert.Equal(1, transport.ConnectCallCount);
|
||||
Assert.Equal(options.Host, transport.ConnectedHost);
|
||||
Assert.Equal(options.Port, transport.ConnectedPort);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_UsesSingleUseClientSemantics_AndDoesNotDisposeExternalTransport()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var transport = new FakeTransport([handshakeAckFrame]);
|
||||
var client = new SuiteLinkClient(transport);
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
await client.DisconnectAsync();
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.Equal(0, transport.DisposeCallCount);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.ConnectAsync(CreateOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WithOwnedTransport_DisposesUnderlyingTransport()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var transport = new FakeTransport([handshakeAckFrame]);
|
||||
var client = new SuiteLinkClient(transport, ownsTransport: true);
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
await client.DisposeAsync();
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.Equal(1, transport.DisposeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_MalformedHandshakeAck_ThrowsAndFaultsClient()
|
||||
{
|
||||
var malformedAck = new byte[] { 0x03, 0x00, 0x02, 0x00, 0xA5 };
|
||||
var transport = new FakeTransport([malformedAck]);
|
||||
var client = new SuiteLinkClient(transport);
|
||||
|
||||
await Assert.ThrowsAsync<FormatException>(() => client.ConnectAsync(CreateOptions()));
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.Equal(1, transport.ConnectCallCount);
|
||||
Assert.Single(transport.SentBuffers); // handshake only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_RemoteEofDuringHandshake_ThrowsAndFaultsClient()
|
||||
{
|
||||
var transport = new FakeTransport(receiveChunks: []);
|
||||
var client = new SuiteLinkClient(transport);
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() => client.ConnectAsync(CreateOptions()));
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.Equal(1, transport.ConnectCallCount);
|
||||
Assert.Single(transport.SentBuffers); // handshake only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_RepeatedWhilePending_DoesNotSendDuplicateStartupFrames()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var transport = new FakeTransport([handshakeAckFrame]);
|
||||
var client = new SuiteLinkClient(transport);
|
||||
var options = CreateOptions();
|
||||
|
||||
await client.ConnectAsync(options);
|
||||
await client.ConnectAsync(options);
|
||||
|
||||
Assert.Equal(1, transport.ConnectCallCount);
|
||||
Assert.Equal(2, transport.SentBuffers.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_ConcurrentCalls_AreSerializedAndDoNotDuplicateStartupFrames()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var receiveGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new FakeTransport([handshakeAckFrame])
|
||||
{
|
||||
ReceiveGate = receiveGate.Task
|
||||
};
|
||||
var client = new SuiteLinkClient(transport);
|
||||
var options = CreateOptions();
|
||||
|
||||
var connectTask1 = client.ConnectAsync(options);
|
||||
while (transport.SentBuffers.Count == 0)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
var connectTask2 = client.ConnectAsync(options);
|
||||
receiveGate.SetResult();
|
||||
|
||||
await Task.WhenAll(connectTask1, connectTask2);
|
||||
|
||||
Assert.Equal(1, transport.ConnectCallCount);
|
||||
Assert.Equal(2, transport.SentBuffers.Count);
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions CreateOptions()
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC",
|
||||
port: 5413);
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Queue<byte[]> _receiveChunks;
|
||||
private bool _disposed;
|
||||
|
||||
public FakeTransport(IEnumerable<byte[]> receiveChunks)
|
||||
{
|
||||
_receiveChunks = new Queue<byte[]>(receiveChunks);
|
||||
}
|
||||
|
||||
public Task? ReceiveGate { get; init; }
|
||||
public string ConnectedHost { get; private set; } = string.Empty;
|
||||
public int ConnectedPort { get; private set; }
|
||||
public int ConnectCallCount { get; private set; }
|
||||
public int DisposeCallCount { get; private set; }
|
||||
public bool IsConnected => ConnectCallCount > 0 && !_disposed;
|
||||
public List<byte[]> SentBuffers { get; } = [];
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ConnectCallCount++;
|
||||
ConnectedHost = host;
|
||||
ConnectedPort = port;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SentBuffers.Add(buffer.ToArray());
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ReceiveGate is not null)
|
||||
{
|
||||
await ReceiveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
byte[]? next;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_receiveChunks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
next = _receiveChunks.Dequeue();
|
||||
}
|
||||
|
||||
next.CopyTo(buffer);
|
||||
return next.Length;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
DisposeCallCount++;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user