From 791d88f06930aae45bb4f51d0b83bdb438c179fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Mon, 25 Aug 2025 17:23:34 +0200 Subject: [PATCH] Added UnitTests --- .../EventArgs/CallMonitorEventArgs.cs | 3 + src/FritzCallMonitor/FritzCallMonitor.csproj | 4 + src/FritzCallMonitor/Utils/Extensions.cs | 1 + .../Utils/ReconnectTcpClient.cs | 50 +- .../Wrappers/NetworkStreamWrapper.cs | 4 - .../CallMonitorEventArgsTest.cs | 154 ++++++ .../ReconnectTcpClientTest.cs | 453 ++++++++++++++++++ 7 files changed, 636 insertions(+), 33 deletions(-) create mode 100644 test/FritzCallMonitor.Tests/CallMonitorEventArgsTest.cs create mode 100644 test/FritzCallMonitor.Tests/ReconnectTcpClientTest.cs diff --git a/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs index 8d02756..6410bb5 100644 --- a/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs +++ b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs @@ -90,6 +90,9 @@ namespace AMWD.Net.Api.Fritz.CallMonitor args.CalleeNumber = columns[4]; args.CallerNumber = columns[5]; break; + + default: + return null; } return args; diff --git a/src/FritzCallMonitor/FritzCallMonitor.csproj b/src/FritzCallMonitor/FritzCallMonitor.csproj index 608c2a4..428dbf6 100644 --- a/src/FritzCallMonitor/FritzCallMonitor.csproj +++ b/src/FritzCallMonitor/FritzCallMonitor.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/src/FritzCallMonitor/Utils/Extensions.cs b/src/FritzCallMonitor/Utils/Extensions.cs index 13039c7..3161627 100644 --- a/src/FritzCallMonitor/Utils/Extensions.cs +++ b/src/FritzCallMonitor/Utils/Extensions.cs @@ -6,6 +6,7 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils { internal static class Extensions { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static async void Forget(this Task task, ILogger? logger = null) { try diff --git a/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs index 406b79a..c8b864c 100644 --- a/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs +++ b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs @@ -24,10 +24,10 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils public ReconnectTcpClient(string host, int port) { if (string.IsNullOrWhiteSpace(host)) - throw new ArgumentNullException(nameof(host), "The host is required."); + throw new ArgumentNullException(nameof(host)); - if (port <= ushort.MinValue || ushort.MaxValue < port) - throw new ArgumentOutOfRangeException(nameof(port), $"The port must be between {ushort.MinValue + 1} and {ushort.MaxValue}."); + if (port < 1 || 65535 < port) + throw new ArgumentOutOfRangeException(nameof(port)); _host = host; _port = port; @@ -98,7 +98,7 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils _tcpClient?.Dispose(); _tcpClient = null; - }); + }, cancellationToken); try { @@ -127,13 +127,13 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils _tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); #if NET6_0_OR_GREATER - var connectTask = _tcpClient.ConnectAsync(_host, _port, cancellationToken); + await _tcpClient.ConnectAsync(_host, _port, cancellationToken).ConfigureAwait(false); #else var connectTask = _tcpClient.ConnectAsync(_host, _port); -#endif - var completedTask = await Task.WhenAny(connectTask, Task.Delay(1000, cancellationToken)).ConfigureAwait(false); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); if (completedTask != connectTask) - throw new TimeoutException("Connection attempt timed out."); + throw new OperationCanceledException("Connection attempt was canceled.", cancellationToken); +#endif if (OnConnected != null) await OnConnected(this).ConfigureAwait(false); @@ -176,34 +176,26 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils byte[] buffer = new byte[1]; while (!cancellationToken.IsCancellationRequested && !_isDisposed) { - if (!IsConnected) + if (_tcpClient == null || !IsConnected) { await ConnectWithRetryAsync(cancellationToken).ConfigureAwait(false); continue; } - var stream = _tcpClient?.GetStream(); - if (stream != null && stream.CanRead) + var stream = _tcpClient.GetStream(); + bool disconnected = false; + try { - bool disconnected = false; - try - { - // Attempt to read zero bytes to check if the connection is still alive. - // Should return immediately if the connection is still active. - // So the timeout of 1sec is more than enough. - var readTask = stream.ReadAsync(buffer, 0, 0, cancellationToken); - var completedTask = await Task.WhenAny(readTask, Task.Delay(1000, cancellationToken)).ConfigureAwait(false); - if (completedTask != readTask) - continue; // Timeout - } - catch - { - disconnected = true; - } - - if (disconnected || !IsConnected) - await ConnectWithRetryAsync(cancellationToken).ConfigureAwait(false); + // Attempt to read zero bytes to check if the connection is still alive. + await stream.ReadAsync(buffer, 0, 0, cancellationToken); } + catch + { + disconnected = true; + } + + if (disconnected) + await ConnectWithRetryAsync(cancellationToken).ConfigureAwait(false); // Check for an active connection every 5 seconds. await Task.Delay(5000, cancellationToken).ConfigureAwait(false); diff --git a/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs b/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs index 2de6ed4..ed40714 100644 --- a/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs +++ b/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs @@ -21,10 +21,6 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Wrappers public virtual void Dispose() => _networkStream.Dispose(); - /// - public virtual bool CanRead => - _networkStream.CanRead; - /// public virtual Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _networkStream.ReadAsync(buffer, offset, count, cancellationToken); diff --git a/test/FritzCallMonitor.Tests/CallMonitorEventArgsTest.cs b/test/FritzCallMonitor.Tests/CallMonitorEventArgsTest.cs new file mode 100644 index 0000000..4620d8f --- /dev/null +++ b/test/FritzCallMonitor.Tests/CallMonitorEventArgsTest.cs @@ -0,0 +1,154 @@ +using System; +using AMWD.Net.Api.Fritz.CallMonitor; + +namespace FritzCallMonitor.Tests +{ + [TestClass] + public class CallMonitorEventArgsTest + { + private string _dateOffset; + + [TestInitialize] + public void Initialize() + { + var offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now); + _dateOffset = offset < TimeSpan.Zero + ? "-" + offset.ToString("hh\\:mm") + : "+" + offset.ToString("hh\\:mm"); + } + + [TestMethod] + public void ShouldParseRingEvent() + { + // Arrange + string line = "25.08.25 20:15:30;RING;2;012345678901;9876543;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + + Assert.IsNotNull(result); + Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", result.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K")); + Assert.AreEqual(EventType.Ring, result.Event); + Assert.AreEqual(2, result.ConnectionId); + Assert.IsNull(result.LinePort); + Assert.AreEqual("012345678901", result.CallerNumber); + Assert.AreEqual("9876543", result.CalleeNumber); + Assert.IsNull(result.Duration); + } + + [TestMethod] + public void ShouldParseConnectEvent() + { + string line = "25.08.25 20:15:30;CONNECT;1;3;012345678901;"; + var result = CallMonitorEventArgs.Parse(line); + + Assert.IsNotNull(result); + Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", result.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K")); + Assert.AreEqual(EventType.Connect, result.Event); + Assert.AreEqual(1, result.ConnectionId); + Assert.AreEqual(3, result.LinePort); + Assert.AreEqual("012345678901", result.CallerNumber); + Assert.IsNull(result.CalleeNumber); + Assert.IsNull(result.Duration); + } + + [TestMethod] + public void ShouldParseDisconnectEvent() + { + string line = "25.08.25 20:15:30;DISCONNECT;2;42;"; + var result = CallMonitorEventArgs.Parse(line); + + Assert.IsNotNull(result); + Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", result.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K")); + Assert.AreEqual(EventType.Disconnect, result.Event); + Assert.AreEqual(2, result.ConnectionId); + Assert.IsNull(result.LinePort); + Assert.IsNull(result.CallerNumber); + Assert.IsNull(result.CalleeNumber); + Assert.AreEqual(TimeSpan.FromSeconds(42), result.Duration); + } + + [TestMethod] + public void ShouldParseCallEvent() + { + string line = "25.08.25 20:15:30;CALL;4;7;9876543;012345678901;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + + Assert.IsNotNull(result); + Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", result.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K")); + Assert.AreEqual(EventType.Call, result.Event); + Assert.AreEqual(4, result.ConnectionId); + Assert.AreEqual(7, result.LinePort); + Assert.AreEqual("012345678901", result.CallerNumber); + Assert.AreEqual("9876543", result.CalleeNumber); + Assert.IsNull(result.Duration); + } + + [TestMethod] + public void ShouldReturnNullOnInvalidDate() + { + string line = "99.99.99 99:99:99;RING;2;012345678901;9876543;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNull(result); + } + + [TestMethod] + public void ShouldReturnNullOnUnknownEventType() + { + string line = "25.08.25 20:15:30;UNKNOWN;2;012345678901;9876543;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNull(result); + } + + [TestMethod] + public void ShouldReturnNullOnInvalidConnectionId() + { + string line = "25.08.25 20:15:30;RING;abc;012345678901;9876543;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNull(result); + } + + [TestMethod] + public void ShouldHandleInvalidLinePortInConnect() + { + string line = "25.08.25 20:15:30;CONNECT;1;abc;012345678901;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNotNull(result); + Assert.IsNull(result.LinePort); + } + + [TestMethod] + public void ShouldHandleInvalidLinePortInCall() + { + string line = "25.08.25 20:15:30;CALL;4;abc;9876543;012345678901;SIP0;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNotNull(result); + Assert.IsNull(result.LinePort); + } + + [TestMethod] + public void ShouldHandleInvalidDurationInDisconnect() + { + string line = "25.08.25 20:15:30;DISCONNECT;2;abc;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNotNull(result); + Assert.IsNull(result.Duration); + } + + [TestMethod] + public void ShouldReturnNullOnTooFewColumns() + { + string line = "25.08.25 20:15:30;RING;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNull(result); + } + + [TestMethod] + public void ShouldParseWithExtraColumns() + { + string line = "25.08.25 20:15:30;RING;2;012345678901;9876543;SIP0;EXTRA;COLUMN;"; + var result = CallMonitorEventArgs.Parse(line); + Assert.IsNotNull(result); + Assert.AreEqual("012345678901", result.CallerNumber); + Assert.AreEqual("9876543", result.CalleeNumber); + } + } +} diff --git a/test/FritzCallMonitor.Tests/ReconnectTcpClientTest.cs b/test/FritzCallMonitor.Tests/ReconnectTcpClientTest.cs new file mode 100644 index 0000000..3879ac0 --- /dev/null +++ b/test/FritzCallMonitor.Tests/ReconnectTcpClientTest.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Fritz.CallMonitor.Utils; +using AMWD.Net.Api.Fritz.CallMonitor.Wrappers; +using Microsoft.Extensions.Logging; +using Moq; + +namespace FritzCallMonitor.Tests +{ + [TestClass] + public class ReconnectTcpClientTest + { + private const int ASYNC_DELAY = 10; + + public TestContext TestContext { get; set; } + + private const int PORT = 4711; + private const string HOST = "localhost"; + + private Mock _socketMock; + private Mock _tcpClientMock; + private Mock _networkStreamMock; + private Mock _tcpClientFactoryMock; + + private bool _tcpClientConnected; + private Queue _tcpClientConnectTaskDelays; + + private Queue _networkStreamReadDelays; + + [TestInitialize] + public void Initialize() + { + _tcpClientConnected = true; + + _tcpClientConnectTaskDelays = new Queue(); + _networkStreamReadDelays = new Queue(); + + _tcpClientConnectTaskDelays.Enqueue(0); + _networkStreamReadDelays.Enqueue(0); + } + + [TestMethod] + public void ShouldCreateInstance() + { + // Arrange + + // Act & Assert + using var client = new ReconnectTcpClient(HOST, PORT); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ShouldThrowArgumentNullExceptionOnMissingHost(string host) + { + // Arrange + + // Act & Assert + Assert.ThrowsExactly(() => new ReconnectTcpClient(host, PORT)); + } + + [TestMethod] + [DataRow(0)] + [DataRow(65536)] + public void ShouldThrowArgumentOutOfRangeExceptionOnInvalidPort(int port) + { + // Arrange + + // Act & Assert + Assert.ThrowsExactly(() => new ReconnectTcpClient(HOST, port)); + } + + [TestMethod] + public async Task ShouldDispose() + { + // Arrange + var client = GetClient(); + await client.StartAsync(TestContext.CancellationTokenSource.Token); + + // Act + client.Dispose(); + + // Assert + _tcpClientMock.Verify(m => m.Dispose(), Times.Once); + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.Once); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldDisposeOnlyOnce() + { + // Arrange + using var client = GetClient(); + await client.StartAsync(TestContext.CancellationTokenSource.Token); + + // Act + client.Dispose(); + client.Dispose(); + + // Assert + _tcpClientMock.Verify(m => m.Dispose(), Times.Once); + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.Once); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldThrowObjectDisposedExceptionOnStart() + { + // Arrange + using var client = GetClient(); + client.Dispose(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => await client.StartAsync(TestContext.CancellationTokenSource.Token)); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldThrowObjectDisposedExceptionOnStop() + { + // Arrange + using var client = GetClient(); + client.Dispose(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => await client.StopAsync(TestContext.CancellationTokenSource.Token)); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowObjectDisposedExceptionOnGetStream() + { + // Arrange + using var client = GetClient(); + client.Dispose(); + + // Act & Assert + Assert.ThrowsExactly(() => client.GetStream()); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldReturnIsConnected() + { + // Arrange + _tcpClientConnectTaskDelays.Enqueue(Timeout.Infinite); + + var client = GetClient(); + await client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + + // Act & Assert + _tcpClientConnected = true; + Assert.IsTrue(client.IsConnected); + + // Act & Assert + _tcpClientConnected = false; + Assert.IsFalse(client.IsConnected); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.AtLeastOnce); + + _tcpClientMock.VerifyGet(m => m.Client, Times.AtLeastOnce); + _tcpClientMock.VerifyGet(m => m.Connected, Times.AtLeast(2)); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.AtLeastOnce); + _tcpClientMock.Verify(m => m.GetStream(), Times.Once); + + _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldCallOnConnectedCallback() + { + // Arrange + var client = GetClient(); + bool callbackCalled = false; + client.OnConnected = c => + { + callbackCalled = true; + return Task.CompletedTask; + }; + + // Act + await client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await client.StopAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + + // Assert + Assert.IsTrue(callbackCalled); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.VerifyGet(m => m.Connected, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.Once); + _tcpClientMock.Verify(m => m.GetStream(), Times.Once); + _tcpClientMock.Verify(m => m.Dispose(), Times.Once); + + _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldStartAndStopWithoutException() + { + // Arrange + var client = GetClient(); + + // Act & Assert + await client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await client.StopAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.VerifyGet(m => m.Connected, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync("localhost", 4711, It.IsAny()), Times.Once); + _tcpClientMock.Verify(m => m.GetStream(), Times.Once); + _tcpClientMock.Verify(m => m.Dispose(), Times.Once); + + _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldStopWithoutStart() + { + // Arrange + var client = GetClient(); + + // Act & Assert + await client.StopAsync(TestContext.CancellationTokenSource.Token); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldReturnStream() + { + // Arrange + var client = GetClient(); + await client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + + // Act + var stream = client.GetStream(); + + // Assert + Assert.IsNotNull(stream); + Assert.AreEqual(_networkStreamMock.Object, stream); + + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.VerifyGet(m => m.Connected, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync("localhost", 4711, It.IsAny()), Times.Once); + _tcpClientMock.Verify(m => m.GetStream(), Times.Exactly(2)); + + _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnNullStreamWhenNotConnected() + { + // Arrange + var client = GetClient(); + + // Act + var stream = client.GetStream(); + + // Assert + Assert.IsNull(stream); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldTerminateCleanly() + { + // Arrange + _tcpClientConnectTaskDelays.Clear(); + _tcpClientConnectTaskDelays.Enqueue(Timeout.Infinite); + using var client = GetClient(); + + // Act + var startTask = client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await client.StopAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await startTask; + + // Assert + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Once); + + _tcpClientMock.VerifyGet(m => m.Client, Times.Once); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.Once); + _tcpClientMock.Verify(m => m.Dispose(), Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task ShouldLogConnectError(bool useLogger) + { + // Arrange + var loggerMock = new Mock(); + + using var client = GetClient(); + if (useLogger) + client.Logger = loggerMock.Object; + + _tcpClientMock + .Setup(m => m.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new SocketException()); + + // Act + var startTask = client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(1000, TestContext.CancellationTokenSource.Token); // Should try to connect two times. + await client.StopAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await startTask; + + // Assert + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.Exactly(2)); + + _tcpClientMock.VerifyGet(m => m.Client, Times.Exactly(2)); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.Exactly(2)); + _tcpClientMock.Verify(m => m.Dispose(), Times.Exactly(2)); + + if (useLogger) + { + loggerMock.Verify( + m => m.Log(LogLevel.Warning, It.IsAny(), + It.Is((v, t) => v.ToString().Equals($"Failed to connect to {HOST}:{PORT}. Retrying in 500ms...")), + It.IsAny(), It.IsAny>()), + Times.Once + ); + loggerMock.Verify( + m => m.Log(LogLevel.Warning, It.IsAny(), + It.Is((v, t) => v.ToString().Equals($"Failed to connect to {HOST}:{PORT}. Retrying in 1000ms...")), + It.IsAny(), It.IsAny>()), + Times.Once + ); + loggerMock.VerifyNoOtherCalls(); + } + + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldSkipReconnect() + { + // Arrange + using var client = GetClient(); + + _networkStreamMock + .Setup(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ObjectDisposedException("Test")); + + // Act + var startTask = client.StartAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(1000, TestContext.CancellationTokenSource.Token); // Should try to connect two times. + await client.StopAsync(TestContext.CancellationTokenSource.Token); + await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token); + await startTask; + + // Assert + _socketMock.Verify(m => m.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true), Times.AtLeastOnce); + + _tcpClientMock.VerifyGet(m => m.Client, Times.AtLeastOnce); + _tcpClientMock.VerifyGet(m => m.Connected, Times.AtLeast(2)); + _tcpClientMock.Verify(m => m.ConnectAsync(HOST, PORT, It.IsAny()), Times.AtLeastOnce); + _tcpClientMock.Verify(m => m.GetStream(), Times.AtLeastOnce); + _tcpClientMock.Verify(m => m.Dispose(), Times.AtLeastOnce); + + _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Once); + + VerifyNoOtherCalls(); + } + + private void VerifyNoOtherCalls() + { + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + private ReconnectTcpClient GetClient() + { + _socketMock = new Mock(null); + _tcpClientMock = new Mock(); + _networkStreamMock = new Mock(null); + _tcpClientFactoryMock = new Mock(); + + _tcpClientMock + .Setup(m => m.Connected) + .Returns(() => _tcpClientConnected); + + _tcpClientMock + .Setup(m => m.Client) + .Returns(() => _socketMock.Object); + + _tcpClientMock + .Setup(m => m.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, __, ct) => Task.Delay(_tcpClientConnectTaskDelays.Dequeue(), ct)); + + _tcpClientMock + .Setup(m => m.GetStream()) + .Returns(() => _networkStreamMock.Object); + + _networkStreamMock + .Setup(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, __, count, ct) => Task.Delay(_networkStreamReadDelays.Dequeue(), ct).ContinueWith(t => count, ct)); + + _tcpClientFactoryMock + .Setup(m => m.Create()) + .Returns(() => _tcpClientMock.Object); + + var client = new ReconnectTcpClient(HOST, PORT); + + var factoryFieldInfo = client.GetType().GetField("_tcpClientFactory", BindingFlags.NonPublic | BindingFlags.Instance); + factoryFieldInfo.SetValue(client, _tcpClientFactoryMock.Object); + + return client; + } + } +}