using SuiteLink.Client.Transport; using System.Net; using System.Net.Sockets; namespace SuiteLink.Client.Tests.Transport; public sealed class SuiteLinkTcpTransportTests { [Fact] public async Task SendAsync_WithInjectedStream_WritesBytes() { using var stream = new MemoryStream(); await using var transport = new SuiteLinkTcpTransport(stream); byte[] payload = [0x01, 0x02, 0x03]; await transport.SendAsync(payload, CancellationToken.None); Assert.Equal(payload, stream.ToArray()); } [Fact] public async Task ReceiveAsync_WithInjectedStream_ReadsBytes() { using var stream = new MemoryStream([0x10, 0x20, 0x30]); await using var transport = new SuiteLinkTcpTransport(stream); byte[] buffer = new byte[2]; var bytesRead = await transport.ReceiveAsync(buffer, CancellationToken.None); Assert.Equal(2, bytesRead); Assert.Equal(0x10, buffer[0]); Assert.Equal(0x20, buffer[1]); } [Fact] public async Task SendAsync_WithoutConnection_ThrowsInvalidOperationException() { await using var transport = new SuiteLinkTcpTransport(); var ex = await Assert.ThrowsAsync( () => transport.SendAsync(new byte[] { 0xAA }, CancellationToken.None).AsTask()); Assert.Contains("connected", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ReceiveAsync_WithPartialReadStream_ReturnsPartialReadsAndEof() { using var stream = new PartialReadStream([0x10, 0x20, 0x30], maxChunkSize: 1); await using var transport = new SuiteLinkTcpTransport(stream); byte[] buffer = new byte[3]; var read1 = await transport.ReceiveAsync(buffer, CancellationToken.None); var read2 = await transport.ReceiveAsync(buffer, CancellationToken.None); var read3 = await transport.ReceiveAsync(buffer, CancellationToken.None); var read4 = await transport.ReceiveAsync(buffer, CancellationToken.None); Assert.Equal(1, read1); Assert.Equal(1, read2); Assert.Equal(1, read3); Assert.Equal(0, read4); } [Fact] public async Task DisposeAsync_AfterDisposal_SendAndReceiveThrowObjectDisposedException() { using var stream = new MemoryStream([0x01, 0x02, 0x03]); var transport = new SuiteLinkTcpTransport(stream); await transport.DisposeAsync(); await Assert.ThrowsAsync( () => transport.SendAsync(new byte[] { 0xAA }, CancellationToken.None).AsTask()); await Assert.ThrowsAsync( () => transport.ReceiveAsync(new byte[1], CancellationToken.None).AsTask()); } [Fact] public async Task DisposeAsync_LeaveOpenTrue_DoesNotDisposeInjectedStream() { var stream = new TrackingStream(); await using (var transport = new SuiteLinkTcpTransport(stream, leaveOpen: true)) { await transport.DisposeAsync(); } Assert.False(stream.WasDisposed); } [Fact] public async Task ConnectAsync_ConcurrentCalls_CreateSingleConnection() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var endpoint = (IPEndPoint)listener.LocalEndpoint; await using var transport = new SuiteLinkTcpTransport(); Task[] connectTasks = [ transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(), transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(), transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(), transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask() ]; await Task.WhenAll(connectTasks); using var accepted1 = await listener.AcceptTcpClientAsync(); using var secondAcceptCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Assert.ThrowsAnyAsync( async () => await listener.AcceptTcpClientAsync(secondAcceptCts.Token)); } [Fact] public async Task ResetConnectionAsync_AfterConnect_AllowsReconnect() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var endpoint = (IPEndPoint)listener.LocalEndpoint; await using var transport = new SuiteLinkTcpTransport(); await transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port); using var accepted1 = await listener.AcceptTcpClientAsync(); await transport.ResetConnectionAsync(); Assert.False(transport.IsConnected); await transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port); using var accepted2 = await listener.AcceptTcpClientAsync(); Assert.True(transport.IsConnected); } [Fact] public async Task ResetConnectionAsync_LeaveOpenTrue_DoesNotDisposeInjectedStream() { var stream = new TrackingStream(); await using var transport = new SuiteLinkTcpTransport(stream, leaveOpen: true); await transport.ResetConnectionAsync(); Assert.False(stream.WasDisposed); } private sealed class PartialReadStream : Stream { private readonly MemoryStream _inner; private readonly int _maxChunkSize; public PartialReadStream(byte[] bytes, int maxChunkSize) { _inner = new MemoryStream(bytes); _maxChunkSize = maxChunkSize; } public override bool CanRead => _inner.CanRead; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => throw new NotSupportedException(); } public override int Read(byte[] buffer, int offset, int count) { return _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize)); } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { return _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxChunkSize)], cancellationToken); } public override void Flush() { throw new NotSupportedException(); } public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } public override void SetLength(long value) { throw new NotSupportedException(); } public override void Write(byte[] buffer, int offset, int count) { throw new NotSupportedException(); } } private sealed class TrackingStream : MemoryStream { public bool WasDisposed { get; private set; } protected override void Dispose(bool disposing) { WasDisposed = true; base.Dispose(disposing); } } }