feat: add suitelink client runtime and test harness

This commit is contained in:
Joseph Doherty
2026-03-16 16:46:32 -04:00
parent 731bfe2237
commit c278f98496
27 changed files with 2515 additions and 15 deletions

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