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