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(() => 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(() => 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(() => 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 _receiveChunks; private bool _disposed; public FakeTransport(IEnumerable receiveChunks) { _receiveChunks = new Queue(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 SentBuffers { get; } = []; public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) { ConnectCallCount++; ConnectedHost = host; ConnectedPort = port; return ValueTask.CompletedTask; } public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { lock (_syncRoot) { SentBuffers.Add(buffer.ToArray()); } return ValueTask.CompletedTask; } public async ValueTask ReceiveAsync(Memory 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; } } }