diff --git a/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs b/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs index 8fb13bc..672cfb3 100644 --- a/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs +++ b/CryptoExchange.Net.UnitTests/AsyncResetEventTests.cs @@ -96,7 +96,7 @@ namespace CryptoExchange.Net.UnitTests waiters.Add(evnt.WaitAsync()); } - List results = null; + List? results = null; var resultsWaiter = Task.Run(async () => { await Task.WhenAll(waiters); @@ -112,7 +112,7 @@ namespace CryptoExchange.Net.UnitTests await resultsWaiter; - Assert.That(10 == results.Count(r => r)); + Assert.That(10 == results?.Count(r => r)); } [Test] diff --git a/CryptoExchange.Net.UnitTests/BodySerializationTests.cs b/CryptoExchange.Net.UnitTests/BodySerializationTests.cs new file mode 100644 index 0000000..97947a1 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/BodySerializationTests.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; +using CryptoExchange.Net; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Converters.SystemTextJson; + +namespace CryptoExchange.Net.UnitTests +{ + internal class BodySerializationTests + { + [Test] + public void ToFormData_SerializesBasicValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = parameters.ToFormData(); + + Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True")); + } + + [Test] + public void JsonSerializer_SerializesBasicValuesCorrectly() + { + var serializer = new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = serializer.Serialize(parameters); + Assert.That(parameterString, Is.EqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}")); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/CallResultTests.cs b/CryptoExchange.Net.UnitTests/CallResultTests.cs index b54bc48..27860bb 100644 --- a/CryptoExchange.Net.UnitTests/CallResultTests.cs +++ b/CryptoExchange.Net.UnitTests/CallResultTests.cs @@ -3,7 +3,6 @@ using CryptoExchange.Net.Objects.Errors; using NUnit.Framework; using NUnit.Framework.Legacy; using System; -using System.Collections.Generic; using System.Net; using System.Net.Http; @@ -17,7 +16,7 @@ namespace CryptoExchange.Net.UnitTests { var result = new CallResult(new ServerError("TestError", ErrorInfo.Unknown)); - ClassicAssert.AreSame(result.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError"); ClassicAssert.IsFalse(result); ClassicAssert.IsFalse(result.Success); } @@ -37,7 +36,7 @@ namespace CryptoExchange.Net.UnitTests { var result = new CallResult(new ServerError("TestError", ErrorInfo.Unknown)); - ClassicAssert.AreSame(result.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(result.Error!.ErrorCode, "TestError"); ClassicAssert.IsNull(result.Data); ClassicAssert.IsFalse(result); ClassicAssert.IsFalse(result.Success); @@ -74,7 +73,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.As(default); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -87,7 +86,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError2"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -100,7 +99,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - ClassicAssert.AreSame(asResult.Error.ErrorCode, "TestError2"); + ClassicAssert.AreSame(asResult.Error!.ErrorCode, "TestError2"); ClassicAssert.IsNull(asResult.Data); ClassicAssert.IsFalse(asResult); ClassicAssert.IsFalse(asResult.Success); @@ -127,7 +126,7 @@ namespace CryptoExchange.Net.UnitTests var asResult = result.AsError(new ServerError("TestError2", ErrorInfo.Unknown)); ClassicAssert.IsNotNull(asResult.Error); - Assert.That(asResult.Error.ErrorCode == "TestError2"); + Assert.That(asResult.Error!.ErrorCode == "TestError2"); Assert.That(asResult.ResponseStatusCode == System.Net.HttpStatusCode.OK); Assert.That(asResult.ResponseTime == TimeSpan.FromSeconds(1)); Assert.That(asResult.RequestUrl == "https://test.com/api"); diff --git a/CryptoExchange.Net.UnitTests/BaseClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs similarity index 72% rename from CryptoExchange.Net.UnitTests/BaseClientTests.cs rename to CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs index e7dfe45..d025316 100644 --- a/CryptoExchange.Net.UnitTests/BaseClientTests.cs +++ b/CryptoExchange.Net.UnitTests/ClientTests/BaseClientTests.cs @@ -1,23 +1,22 @@ using NUnit.Framework; -using NUnit.Framework.Legacy; -namespace CryptoExchange.Net.UnitTests +namespace CryptoExchange.Net.UnitTests.ClientTests { [TestFixture()] public class BaseClientTests { - [TestCase] - public void DeserializingValidJson_Should_GiveSuccessfulResult() - { - // arrange - var client = new TestBaseClient(); + //[TestCase] + //public void DeserializingValidJson_Should_GiveSuccessfulResult() + //{ + // // arrange + // var client = new TestBaseClient(); - // act - var result = client.SubClient.Deserialize("{\"testProperty\": 123}"); + // // act + // var result = client.SubClient.Deserialize("{\"testProperty\": 123}"); - // assert - Assert.That(result.Success); - } + // // assert + // Assert.That(result.Success); + //} [TestCase("https://api.test.com/api", new[] { "path1", "path2" }, "https://api.test.com/api/path1/path2")] [TestCase("https://api.test.com/api", new[] { "path1", "/path2" }, "https://api.test.com/api/path1/path2")] diff --git a/CryptoExchange.Net.UnitTests/RestClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs similarity index 79% rename from CryptoExchange.Net.UnitTests/RestClientTests.cs rename to CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs index d17a456..4e36ac4 100644 --- a/CryptoExchange.Net.UnitTests/RestClientTests.cs +++ b/CryptoExchange.Net.UnitTests/ClientTests/RestClientTests.cs @@ -1,9 +1,6 @@ using CryptoExchange.Net.Objects; -using CryptoExchange.Net.UnitTests.TestImplementations; using NUnit.Framework; using System; -using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading.Tasks; using System.Threading; @@ -13,22 +10,25 @@ using CryptoExchange.Net.RateLimiting.Guards; using CryptoExchange.Net.RateLimiting.Filters; using CryptoExchange.Net.RateLimiting.Interfaces; using System.Text.Json; +using CryptoExchange.Net.UnitTests.Implementations; +using CryptoExchange.Net.Testing; -namespace CryptoExchange.Net.UnitTests +namespace CryptoExchange.Net.UnitTests.ClientTests { [TestFixture()] public class RestClientTests { [TestCase] - public void RequestingData_Should_ResultInData() + public async Task RequestingData_Should_ResultInData() { // arrange var client = new TestRestClient(); var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; - client.SetResponse(JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }), out _); + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + client.ApiClient1.SetNextResponse(strData, System.Net.HttpStatusCode.OK); // act - var result = client.Api1.Request().Result; + var result = await client.ApiClient1.GetResponseAsync(); // assert Assert.That(result.Success); @@ -36,14 +36,14 @@ namespace CryptoExchange.Net.UnitTests } [TestCase] - public void ReceivingInvalidData_Should_ResultInError() + public async Task ReceivingInvalidData_Should_ResultInError() { // arrange var client = new TestRestClient(); - client.SetResponse("{\"property\": 123", out _); + client.ApiClient1.SetNextResponse("{\"property\": 123", System.Net.HttpStatusCode.OK); // act - var result = client.Api1.Request().Result; + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -55,10 +55,10 @@ namespace CryptoExchange.Net.UnitTests { // arrange var client = new TestRestClient(); - client.SetErrorWithoutResponse(System.Net.HttpStatusCode.BadRequest, "Invalid request"); + client.ApiClient1.SetNextResponse("Invalid request", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -70,10 +70,10 @@ namespace CryptoExchange.Net.UnitTests { // arrange var client = new TestRestClient(); - client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); + client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); @@ -81,60 +81,42 @@ namespace CryptoExchange.Net.UnitTests Assert.That(result.Error is ServerError); } - [TestCase] public async Task ReceivingErrorAndNotParsingErrorAndInvalidJson_Should_ContainData() { // arrange var client = new TestRestClient(); var response = "..."; - client.SetErrorWithResponse(response, System.Net.HttpStatusCode.BadRequest); + client.ApiClient1.SetNextResponse(response, System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api1.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); Assert.That(result.Error != null); Assert.That(result.Error is DeserializeError); - Assert.That(result.Error.Message.Contains(response)); + Assert.That(result.Error!.Message!.Contains(response)); } [TestCase] public async Task ReceivingErrorAndParsingError_Should_ResultInParsedError() { // arrange - var client = new ParseErrorTestRestClient(); - client.SetErrorWithResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); + var client = new TestRestClient(); + client.ApiClient1.SetNextResponse("{\"errorMessage\": \"Invalid request\", \"errorCode\": 123}", System.Net.HttpStatusCode.BadRequest); // act - var result = await client.Api2.Request(); + var result = await client.ApiClient1.GetResponseAsync(); // assert ClassicAssert.IsFalse(result.Success); Assert.That(result.Error != null); Assert.That(result.Error is ServerError); - Assert.That(result.Error.ErrorCode == "123"); + Assert.That(result.Error!.ErrorCode == "123"); Assert.That(result.Error.Message == "Invalid request"); } - [TestCase] - public void SettingOptions_Should_ResultInOptionsSet() - { - // arrange - // act - var options = new TestClientOptions(); - options.Api1Options.TimestampRecalculationInterval = TimeSpan.FromMinutes(10); - options.Api1Options.OutputOriginalData = true; - options.RequestTimeout = TimeSpan.FromMinutes(1); - var client = new TestBaseClient(options); - - // assert - Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.TimestampRecalculationInterval == TimeSpan.FromMinutes(10)); - Assert.That(((TestClientOptions)client.ClientOptions).Api1Options.OutputOriginalData == true); - Assert.That(((TestClientOptions)client.ClientOptions).RequestTimeout == TimeSpan.FromMinutes(1)); - } - [TestCase("GET", HttpMethodParameterPosition.InUri)] // No need to test InBody for GET since thats not valid [TestCase("POST", HttpMethodParameterPosition.InBody)] [TestCase("POST", HttpMethodParameterPosition.InUri)] @@ -148,28 +130,22 @@ namespace CryptoExchange.Net.UnitTests // act var client = new TestRestClient(); - client.Api1.SetParameterPosition(new HttpMethod(method), pos); + var httpMethod = new HttpMethod(method); + client.ApiClient1.SetParameterPosition(httpMethod, pos); + client.ApiClient1.SetNextResponse("{}", System.Net.HttpStatusCode.OK); - client.SetResponse("{}", out var request); - - await client.Api1.RequestWithParams(new HttpMethod(method), new ParameterCollection + var result = await client.ApiClient1.GetResponseAsync(httpMethod, new ParameterCollection { { "TestParam1", "Value1" }, { "TestParam2", 2 }, - }, - new Dictionary - { - { "TestHeader", "123" } }); // assert - Assert.That(request.Method == new HttpMethod(method)); - Assert.That((request.Content?.Contains("TestParam1") == true) == (pos == HttpMethodParameterPosition.InBody)); - Assert.That((request.Uri.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri)); - Assert.That((request.Content?.Contains("TestParam2") == true) == (pos == HttpMethodParameterPosition.InBody)); - Assert.That((request.Uri.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri)); - Assert.That(request.GetHeaders().First().Key == "TestHeader"); - Assert.That(request.GetHeaders().First().Value.Contains("123")); + Assert.That(result.RequestMethod == new HttpMethod(method)); + Assert.That(result.RequestBody?.Contains("TestParam1") == true == (pos == HttpMethodParameterPosition.InBody)); + Assert.That((result.RequestUrl?.ToString().Contains("TestParam1")) == (pos == HttpMethodParameterPosition.InUri)); + Assert.That(result.RequestBody?.Contains("TestParam2") == true == (pos == HttpMethodParameterPosition.InBody)); + Assert.That((result.RequestUrl?.ToString().Contains("TestParam2")) == (pos == HttpMethodParameterPosition.InUri)); } @@ -188,12 +164,12 @@ namespace CryptoExchange.Net.UnitTests for (var i = 0; i < requests + 1; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - Assert.That(i == requests? triggered : !triggered); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); + Assert.That(i == requests ? triggered : !triggered); } triggered = false; await Task.Delay((int)Math.Round(perSeconds * 1000) + 10); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); Assert.That(!triggered); } @@ -209,12 +185,12 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimiting ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -231,7 +207,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -271,15 +247,15 @@ namespace CryptoExchange.Net.UnitTests { var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathFilter("/sapi/test"), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); - + var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -294,12 +270,12 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerEndpoint, new ExactPathsFilter(new[] { "/sapi/test", "/sapi/test2" }), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); var requestDefinition = new RequestDefinition(endpoint, HttpMethod.Get); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); - bool expected = i == 1 ? (expectLimited ? evnt.DelayTime > TimeSpan.Zero : evnt == null) : evnt == null; + bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } } @@ -318,7 +294,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get) { Authenticated = key1 != null }; var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = key2 != null }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", key1, 1, RateLimitingBehaviour.Wait, null, default); @@ -337,7 +313,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "https://test.com", "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -357,7 +333,7 @@ namespace CryptoExchange.Net.UnitTests var requestDefinition1 = new RequestDefinition(endpoint1, HttpMethod.Get); var requestDefinition2 = new RequestDefinition(endpoint2, HttpMethod.Get) { Authenticated = true }; - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, host1, "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -374,7 +350,7 @@ namespace CryptoExchange.Net.UnitTests var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(0.1), RateLimitWindowType.Fixed)); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("1", HttpMethod.Get), host1, "123", 1, RateLimitingBehaviour.Wait, null, default); @@ -389,7 +365,7 @@ namespace CryptoExchange.Net.UnitTests var rateLimiter = new RateLimitGate("Test"); rateLimiter.AddGuard(new RateLimitGuard(RateLimitGuard.PerHost, new LimitItemTypeFilter(RateLimitItemType.Connection), 1, TimeSpan.FromSeconds(10), RateLimitWindowType.Fixed)); - RateLimitEvent evnt = null; + RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2)); diff --git a/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs b/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs new file mode 100644 index 0000000..5126809 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ClientTests/SocketClientTests.cs @@ -0,0 +1,177 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Testing; +using CryptoExchange.Net.UnitTests.Implementations; +using NUnit.Framework; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.ClientTests +{ + [TestFixture] + public class SocketClientTests + { + [TestCase] + public void SettingOptions_Should_ResultInOptionsSet() + { + //arrange + //act + var client = new TestSocketClient(options => + { + options.ExchangeOptions.MaxSocketConnections = 1; + }); + + //assert + Assert.That(1 == client.ApiClient1.ApiOptions.MaxSocketConnections); + } + + [TestCase(true)] + [TestCase(false)] + public async Task ConnectSocket_Should_ReturnConnectionResult(bool canConnect) + { + //arrange + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + socket.CanConnect = canConnect; + + //act + var connectResult = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + //assert + Assert.That(connectResult.Success == canConnect); + } + + [TestCase] + public async Task SocketMessages_Should_BeProcessedInDataHandlers() + { + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + + var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + + TestObject? received = null; + var resetEvent = new AsyncResetEvent(false); + + await client.ApiClient1.SubscribeToUpdatesAsync(x => + { + received = x.Data; + resetEvent.Set(); + }, false, default); + + socket.InvokeMessage(strData); + await resetEvent.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.That(received != null); + } + + [TestCase(false)] + [TestCase(true)] + public async Task SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled) + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + options.ExchangeOptions.OutputOriginalData = enabled; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var expected = new TestObject() { DecimalData = 1.23M, IntData = 10, StringData = "Some data" }; + var strData = JsonSerializer.Serialize(expected, new JsonSerializerOptions { TypeInfoResolver = new TestSerializerContext() }); + + string? originalData = null; + var resetEvent = new AsyncResetEvent(false); + + await client.ApiClient1.SubscribeToUpdatesAsync(x => + { + originalData = x.OriginalData; + resetEvent.Set(); + }, false, default); + + socket.InvokeMessage(strData); + await resetEvent.WaitAsync(TimeSpan.FromSeconds(1)); + + // assert + Assert.That(originalData == (enabled ? strData : null)); + } + + [TestCase()] + public async Task UnsubscribingStream_Should_CloseTheSocket() + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + + var result = await client.ApiClient1.SubscribeToUpdatesAsync(x => {}, false, default); + + // act + await client.UnsubscribeAsync(result.Data); + + // assert + Assert.That(socket.Connected == false); + } + + [TestCase()] + public async Task UnsubscribingAll_Should_CloseAllSockets() + { + // arrange + var client = new TestSocketClient(options => + { + options.ReconnectInterval = TimeSpan.Zero; + }); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var result = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + var socket2 = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var result2 = await client.ApiClient1.SubscribeToUpdatesAsync(x => { }, false, default); + + // act + await client.UnsubscribeAllAsync(); + + // assert + Assert.That(socket.Connected == false); + Assert.That(socket2.Connected == false); + } + + [TestCase()] + public async Task ErrorResponse_ShouldNot_ConfirmSubscription() + { + // arrange + var client = new TestSocketClient(opt => + { + opt.OutputOriginalData = true; + }); + + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var subTask = client.ApiClient1.SubscribeToUpdatesAsync(x => { }, true, default); + + socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "ErrorWithSub" })); + + var result = await subTask; + + // assert + Assert.That(result.Success == false); + Assert.That(result.Error!.Message!.Contains("ErrorWithSub")); + } + + [TestCase()] + public async Task SuccessResponse_Should_ConfirmSubscription() + { + var client = new TestSocketClient(); + var socket = TestHelpers.ConfigureSocketClient(client, "wss://localhost"); + var subTask = client.ApiClient1.SubscribeToUpdatesAsync(x => { }, true, default); + + socket.InvokeMessage(JsonSerializer.Serialize(new TestSocketMessage { Id = 1, Data = "OK" })); + + var result = await subTask; + + var subscription = client.ApiClient1._socketConnections.Single().Value.Subscriptions.Single(); + Assert.That(subscription.Status == SubscriptionStatus.Subscribed); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs new file mode 100644 index 0000000..6069359 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/ArrayConverterTests.cs @@ -0,0 +1,109 @@ +using CryptoExchange.Net.Attributes; +using CryptoExchange.Net.Converters; +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class ArrayConverterTests + { + [Test()] + public void TestArrayConverter() + { + var data = new Test() + { + Prop1 = 2, + Prop2 = null, + Prop3 = "123", + Prop3Again = "123", + Prop4 = null, + Prop5 = new Test2 + { + Prop21 = 3, + Prop22 = "456" + }, + Prop6 = new Test3 + { + Prop31 = 4, + Prop32 = "789" + }, + Prop7 = TestEnum.Two, + TestInternal = new Test + { + Prop1 = 10 + }, + Prop8 = new Test3 + { + Prop31 = 5, + Prop32 = "101" + }, + }; + + var options = new JsonSerializerOptions() + { + TypeInfoResolver = new TestSerializerContext() + }; + var serialized = JsonSerializer.Serialize(data); + var deserialized = JsonSerializer.Deserialize(serialized); + + Assert.That(deserialized!.Prop1, Is.EqualTo(2)); + Assert.That(deserialized.Prop2, Is.Null); + Assert.That(deserialized.Prop3, Is.EqualTo("123")); + Assert.That(deserialized.Prop3Again, Is.EqualTo("123")); + Assert.That(deserialized.Prop4, Is.Null); + Assert.That(deserialized.Prop5!.Prop21, Is.EqualTo(3)); + Assert.That(deserialized.Prop5!.Prop22, Is.EqualTo("456")); + Assert.That(deserialized.Prop6!.Prop31, Is.EqualTo(4)); + Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789")); + Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two)); + Assert.That(deserialized.TestInternal!.Prop1, Is.EqualTo(10)); + Assert.That(deserialized.Prop8!.Prop31, Is.EqualTo(5)); + Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101")); + } + } + + [JsonConverter(typeof(ArrayConverter))] + public record Test + { + [ArrayProperty(0)] + public int Prop1 { get; set; } + [ArrayProperty(1)] + public int? Prop2 { get; set; } + [ArrayProperty(2)] + public string? Prop3 { get; set; } + [ArrayProperty(2)] + public string? Prop3Again { get; set; } + [ArrayProperty(3)] + public string? Prop4 { get; set; } + [ArrayProperty(4)] + public Test2? Prop5 { get; set; } + [ArrayProperty(5)] + public Test3? Prop6 { get; set; } + [ArrayProperty(6), JsonConverter(typeof(EnumConverter))] + public TestEnum? Prop7 { get; set; } + [ArrayProperty(7)] + public Test? TestInternal { get; set; } + [ArrayProperty(8), JsonConversion] + public Test3? Prop8 { get; set; } + } + + [JsonConverter(typeof(ArrayConverter))] + public record Test2 + { + [ArrayProperty(0)] + public int Prop21 { get; set; } + [ArrayProperty(1)] + public string? Prop22 { get; set; } + } + + public record Test3 + { + [JsonPropertyName("prop31")] + public int Prop31 { get; set; } + [JsonPropertyName("prop32")] + public string? Prop32 { get; set; } + } + +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs new file mode 100644 index 0000000..b65d1ed --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/BoolConverterTests.cs @@ -0,0 +1,57 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class BoolConverterTests + { + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("yes", true)] + [TestCase("y", true)] + [TestCase("on", true)] + [TestCase("-1", false)] + [TestCase("0", false)] + [TestCase("n", false)] + [TestCase("no", false)] + [TestCase("false", false)] + [TestCase("off", false)] + [TestCase("", null)] + public void TestBoolConverter(string value, bool? expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("yes", true)] + [TestCase("y", true)] + [TestCase("on", true)] + [TestCase("-1", false)] + [TestCase("0", false)] + [TestCase("n", false)] + [TestCase("no", false)] + [TestCase("false", false)] + [TestCase("off", false)] + [TestCase("", false)] + public void TestBoolConverterNotNullable(string value, bool expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + } + + public class STJBoolObject + { + public bool? Value { get; set; } + } + + public class NotNullableSTJBoolObject + { + public bool Value { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs new file mode 100644 index 0000000..4892a05 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/DateTimeConverterTests.cs @@ -0,0 +1,119 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class DateTimeConverterTests + { + [TestCase("2021-05-12")] + [TestCase("20210512")] + [TestCase("210512")] + [TestCase("1620777600.000")] + [TestCase("1620777600000")] + [TestCase("2021-05-12T00:00:00.000Z")] + [TestCase("2021-05-12T00:00:00.000000000Z")] + [TestCase("0.000000", true)] + [TestCase("0", true)] + [TestCase("", true)] + [TestCase(" ", true)] + public void TestDateTimeConverterString(string input, bool expectNull = false) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": \"{input}\" }}"); + Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); + } + + [TestCase(1620777600.000)] + [TestCase(1620777600000d)] + public void TestDateTimeConverterDouble(double input) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); + Assert.That(output!.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [TestCase(1620777600)] + [TestCase(1620777600000)] + [TestCase(1620777600000000)] + [TestCase(1620777600000000000)] + [TestCase(0, true)] + public void TestDateTimeConverterLong(long input, bool expectNull = false) + { + var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); + Assert.That(output!.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); + } + + [TestCase(1620777600)] + [TestCase(1620777600.000)] + public void TestDateTimeConverterFromSeconds(double input) + { + var output = DateTimeConverter.ConvertFromSeconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToSeconds() + { + var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600); + } + + [TestCase(1620777600000)] + [TestCase(1620777600000.000)] + public void TestDateTimeConverterFromMilliseconds(double input) + { + var output = DateTimeConverter.ConvertFromMilliseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToMilliseconds() + { + var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000); + } + + [TestCase(1620777600000000)] + public void TestDateTimeConverterFromMicroseconds(long input) + { + var output = DateTimeConverter.ConvertFromMicroseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToMicroseconds() + { + var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000000); + } + + [TestCase(1620777600000000000)] + public void TestDateTimeConverterFromNanoseconds(long input) + { + var output = DateTimeConverter.ConvertFromNanoseconds(input); + Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public void TestDateTimeConverterToNanoseconds() + { + var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(output == 1620777600000000000); + } + + [TestCase()] + public void TestDateTimeConverterNull() + { + var output = JsonSerializer.Deserialize($"{{ \"time\": null }}"); + Assert.That(output!.Time == null); + } + } + + public class STJTimeObject + { + [JsonConverter(typeof(DateTimeConverter))] + [JsonPropertyName("time")] + public DateTime? Time { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs new file mode 100644 index 0000000..b7302d7 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/DecimalConverterTests.cs @@ -0,0 +1,49 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using NUnit.Framework; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class DecimalConverterTests + { + [TestCase("1", 1)] + [TestCase("1.1", 1.1)] + [TestCase("-1.1", -1.1)] + [TestCase(null, null)] + [TestCase("", null)] + [TestCase("null", null)] + [TestCase("nan", null)] + [TestCase("1E+2", 100)] + [TestCase("1E-2", 0.01)] + [TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue + [TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + [TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue + [TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + public void TestDecimalConverterString(string value, decimal? expected) + { + var result = JsonSerializer.Deserialize("{ \"test\": \"" + value + "\"}"); + Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue : expected)); + } + + [TestCase("1", 1)] + [TestCase("1.1", 1.1)] + [TestCase("-1.1", -1.1)] + [TestCase("null", null)] + [TestCase("1E+2", 100)] + [TestCase("1E-2", 0.01)] + [TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue + public void TestDecimalConverterNumber(string value, decimal? expected) + { + var result = JsonSerializer.Deserialize("{ \"test\": " + value + "}"); + Assert.That(result!.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected)); + } + } + + public class STJDecimalObject + { + [JsonConverter(typeof(DecimalConverter))] + [JsonPropertyName("test")] + public decimal? Test { get; set; } + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs new file mode 100644 index 0000000..ee9b1d8 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/EnumConverterTests.cs @@ -0,0 +1,147 @@ +using CryptoExchange.Net.Attributes; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Testing; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + public class EnumConverterTests + { + [TestCase(TestEnum.One, "1")] + [TestCase(TestEnum.Two, "2")] + [TestCase(TestEnum.Three, "three")] + [TestCase(TestEnum.Four, "Four")] + [TestCase(null, null)] + public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected) + { + var output = EnumConverter.GetString(value); + Assert.That(output == expected); + } + + [TestCase(TestEnum.One, "1")] + [TestCase(TestEnum.Two, "2")] + [TestCase(TestEnum.Three, "three")] + [TestCase(TestEnum.Four, "Four")] + public void TestEnumConverterGetStringTests(TestEnum value, string expected) + { + var output = EnumConverter.GetString(value); + Assert.That(output == expected); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", null)] + [TestCase(null, null)] + public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new TestSerializerContext())); + Assert.That(output!.Value == expected); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", (TestEnum)(-9))] + [TestCase(null, (TestEnum)(-9))] + public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected) + { + var val = value == null ? "null" : $"\"{value}\""; + var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}"); + Assert.That(output!.Value == expected); + } + + [Test] + public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined() + { + var output = JsonSerializer.Deserialize($"\"TestUndefined\""); + Assert.That((int)output == -99); + } + + [TestCase("1", TestEnum.One)] + [TestCase("2", TestEnum.Two)] + [TestCase("3", TestEnum.Three)] + [TestCase("three", TestEnum.Three)] + [TestCase("Four", TestEnum.Four)] + [TestCase("four", TestEnum.Four)] + [TestCase("Four1", null)] + [TestCase(null, null)] + public void TestEnumConverterParseStringTests(string value, TestEnum? expected) + { + var result = EnumConverter.ParseString(value); + Assert.That(result == expected); + } + + [Test] + public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce() + { + LibraryHelpers.StaticLogger = new TraceLogger(); + var listener = new EnumValueTraceListener(); + Trace.Listeners.Add(listener); + EnumConverter.Reset(); + try + { + Assert.Throws(() => + { + var result = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext())); + }); + + Assert.DoesNotThrow(() => + { + var result2 = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new TestSerializerContext())); + }); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + } + public class STJEnumObject + { + public TestEnum? Value { get; set; } + } + + public class NotNullableSTJEnumObject + { + public TestEnum Value { get; set; } + } + + [JsonConverter(typeof(EnumConverter))] + public enum TestEnum + { + [Map("1")] + One, + [Map("2")] + Two, + [Map("three", "3")] + Three, + Four + } + + [JsonConverter(typeof(EnumConverter))] + public enum TestEnum2 + { + [Map("-9")] + Minus9 = -9, + [Map("1")] + One, + [Map("2")] + Two, + [Map("three", "3")] + Three, + Four + } +} diff --git a/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs b/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs new file mode 100644 index 0000000..4d788cd --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ConverterTests/SharedModelConversionTests.cs @@ -0,0 +1,46 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.SharedApis; +using NUnit.Framework; +using System; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.ConverterTests +{ + [TestFixture()] + public class SharedModelConversionTests + { + [TestCase(TradingMode.Spot, "ETH", "USDT", null)] + [TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)] + [TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)] + public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime) + { + DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value); + var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time); + + var serialized = JsonSerializer.Serialize(symbol); + var restored = JsonSerializer.Deserialize(serialized); + + Assert.That(restored!.TradingMode, Is.EqualTo(symbol.TradingMode)); + Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset)); + Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset)); + Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime)); + } + + [TestCase(0.1, null, null)] + [TestCase(0.1, 0.1, null)] + [TestCase(0.1, 0.1, 0.1)] + [TestCase(null, 0.1, null)] + [TestCase(null, 0.1, 0.1)] + public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity) + { + var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity); + + var serialized = JsonSerializer.Serialize(symbol); + var restored = JsonSerializer.Deserialize(serialized); + + Assert.That(restored!.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset)); + Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset)); + Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj b/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj index b29899c..05feaa0 100644 --- a/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj +++ b/CryptoExchange.Net.UnitTests/CryptoExchange.Net.UnitTests.csproj @@ -3,6 +3,7 @@ net10.0 false + enable diff --git a/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs b/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs index c11c41b..61dda9e 100644 --- a/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs +++ b/CryptoExchange.Net.UnitTests/ExchangeSymbolCacheTests.cs @@ -326,7 +326,7 @@ namespace CryptoExchange.Net.UnitTests // assert Assert.That(result, Is.Not.Null); - Assert.That(result.BaseAsset, Is.EqualTo("BTC")); + Assert.That(result!.BaseAsset, Is.EqualTo("BTC")); Assert.That(result.QuoteAsset, Is.EqualTo("USDT")); Assert.That(result.TradingMode, Is.EqualTo(TradingMode.Spot)); Assert.That(result.SymbolName, Is.EqualTo("BTCUSDT")); diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs b/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs new file mode 100644 index 0000000..8027c0f --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestAuthenticationProvider.cs @@ -0,0 +1,20 @@ +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Objects; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestAuthenticationProvider : AuthenticationProvider + { + public TestAuthenticationProvider(TestCredentials credentials) : base(credentials, credentials) + { + } + + public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig) + { + requestConfig.Headers ??= new Dictionary(); + requestConfig.Headers["Authorization"] = Credential.Key; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs b/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs new file mode 100644 index 0000000..d995f4f --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestCredentials.cs @@ -0,0 +1,30 @@ +using CryptoExchange.Net.Authentication; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestCredentials : HMACCredential + { + public TestCredentials() { } + + public TestCredentials(string key, string secret) : base(key, secret) + { + } + + public TestCredentials(HMACCredential credential) : base(credential.Key, credential.Secret) + { + } + + public TestCredentials WithHMAC(string key, string secret) + { + if (!string.IsNullOrEmpty(Key)) throw new InvalidOperationException("Credentials already set"); + + Key = key; + Secret = secret; + return this; + } + + /// + public override ApiCredentials Copy() => new TestCredentials(this); + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs b/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs new file mode 100644 index 0000000..6caeddd --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestEnvironment.cs @@ -0,0 +1,64 @@ +using CryptoExchange.Net.Objects; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestEnvironment : TradeEnvironment + { + public string RestClientAddress { get; } + public string SocketClientAddress { get; } + + internal TestEnvironment( + string name, + string restAddress, + string streamAddress) : + base(name) + { + RestClientAddress = restAddress; + SocketClientAddress = streamAddress; + } + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + public TestEnvironment() : base(TradeEnvironmentNames.Live) +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + { } + + /// + /// Get the environment by name + /// + public static TestEnvironment? GetEnvironmentByName(string? name) + => name switch + { + TradeEnvironmentNames.Live => Live, + "" => Live, + null => Live, + _ => default + }; + + /// + /// Available environment names + /// + /// + public static string[] All => [Live.Name]; + + /// + /// Live environment + /// + public static TestEnvironment Live { get; } + = new TestEnvironment(TradeEnvironmentNames.Live, + "https://localhost", + "wss://localhost"); + + /// + /// Create a custom environment + /// + /// + /// + /// + /// + public static TestEnvironment CreateCustom( + string name, + string spotRestAddress, + string spotSocketStreamsAddress) + => new TestEnvironment(name, spotRestAddress, spotSocketStreamsAddress); + } +} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs b/CryptoExchange.Net.UnitTests/Implementations/TestObject.cs similarity index 71% rename from CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs rename to CryptoExchange.Net.UnitTests/Implementations/TestObject.cs index 7827d0c..99006fa 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestObjects.cs +++ b/CryptoExchange.Net.UnitTests/Implementations/TestObject.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; -namespace CryptoExchange.Net.UnitTests.TestImplementations +namespace CryptoExchange.Net.UnitTests.Implementations { public class TestObject { [JsonPropertyName("other")] - public string StringData { get; set; } + public string StringData { get; set; } = string.Empty; [JsonPropertyName("intData")] public int IntData { get; set; } [JsonPropertyName("decimalData")] diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs b/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs new file mode 100644 index 0000000..05dd240 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestQuery.cs @@ -0,0 +1,25 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Errors; +using CryptoExchange.Net.Sockets; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestQuery : Query + { + public TestQuery(TestSocketMessage request, bool authenticated) : base(request, authenticated, 1) + { + MessageRouter = MessageRouter.CreateWithoutTopicFilter(request.Id.ToString(), HandleMessage); + } + + private CallResult? HandleMessage(SocketConnection connection, DateTime time, string? arg3, TestSocketMessage message) + { + if (message.Data != "OK") + return new CallResult(new ServerError(ErrorInfo.Unknown with { Message = message.Data })); + + return CallResult.SuccessResult; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs new file mode 100644 index 0000000..a89a7b4 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestApiClient.cs @@ -0,0 +1,64 @@ +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.SharedApis; +using CryptoExchange.Net.Testing.Implementations; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestApiClient : RestApiClient + { + protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); + + public TestRestApiClient(ILogger logger, HttpClient? httpClient, TestRestOptions options) + : base(logger, httpClient, options.Environment.RestClientAddress, options, options.ExchangeOptions) + { + } + + + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) => + baseAsset + quoteAsset; + + protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) => + new TestAuthenticationProvider(credentials); + + protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + + internal void SetNextResponse(string data, HttpStatusCode code) + { + var expectedBytes = Encoding.UTF8.GetBytes(data); + var responseStream = new MemoryStream(); + responseStream.Write(expectedBytes, 0, expectedBytes.Length); + responseStream.Seek(0, SeekOrigin.Begin); + + var response = new TestResponse(code, responseStream); + var request = new TestRequest(response); + + var factory = new TestRequestFactory(request); + RequestFactory = factory; + } + + internal async Task> GetResponseAsync(HttpMethod? httpMethod = null, ParameterCollection? collection = null) + { + var definition = new RequestDefinition("/path", httpMethod ?? HttpMethod.Get) + { + Weight = 0 + }; + return await SendAsync(BaseAddress, definition, collection ?? new ParameterCollection(), default); + } + + internal void SetParameterPosition(HttpMethod httpMethod, HttpMethodParameterPosition pos) + { + ParameterPositions[httpMethod] = pos; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs new file mode 100644 index 0000000..e812220 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestClient.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Net.Http; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestClient : BaseRestClient + { + public TestRestApiClient ApiClient1 { get; set; } + public TestRestApiClient ApiClient2 { get; set; } + + public TestRestClient(Action? optionsDelegate = null) + : this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) + { + } + + public TestRestClient(HttpClient? httpClient, ILoggerFactory? loggerFactory, IOptions options) : base(loggerFactory, "Test") + { + Initialize(options.Value); + + ApiClient1 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value)); + ApiClient2 = AddApiClient(new TestRestApiClient(_logger, httpClient, options.Value)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs new file mode 100644 index 0000000..9bf8d0a --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestMessageHandler.cs @@ -0,0 +1,33 @@ +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Errors; +using System.IO; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestMessageHandler : JsonRestMessageHandler + { + public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext()); + + public override async ValueTask ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream) + { + var (jsonError, jsonDocument) = await GetJsonDocument(responseStream).ConfigureAwait(false); + if (jsonError != null) + return jsonError; + + int? code = jsonDocument!.RootElement.TryGetProperty("errorCode", out var codeProp) ? codeProp.GetInt32() : null; + var msg = jsonDocument.RootElement.TryGetProperty("errorMessage", out var msgProp) ? msgProp.GetString() : null; + if (msg == null) + return new ServerError(ErrorInfo.Unknown); + + if (code == null) + return new ServerError(ErrorInfo.Unknown with { Message = msg }); + + return new ServerError(code.Value, new ErrorInfo(ErrorType.Unknown, false, "Error") with { Message = msg }); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs b/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs new file mode 100644 index 0000000..7547208 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestRestOptions.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Objects.Options; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestRestOptions : RestExchangeOptions + { + internal static TestRestOptions Default { get; set; } = new TestRestOptions() + { + Environment = TestEnvironment.Live, + AutoTimestamp = true + }; + + public TestRestOptions() + { + Default?.Set(this); + } + + public RestApiOptions ExchangeOptions { get; private set; } = new RestApiOptions(); + + internal TestRestOptions Set(TestRestOptions targetOptions) + { + targetOptions = base.Set(targetOptions); + targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions); + return targetOptions; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs new file mode 100644 index 0000000..6dd617c --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSerializerContext.cs @@ -0,0 +1,29 @@ +using CryptoExchange.Net.UnitTests.ConverterTests; +using CryptoExchange.Net.UnitTests.Implementations; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests +{ + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(TestObject))] + + [JsonSerializable(typeof(TestSocketMessage))] + [JsonSerializable(typeof(Test))] + [JsonSerializable(typeof(Test2))] + [JsonSerializable(typeof(Test3))] + [JsonSerializable(typeof(NotNullableSTJBoolObject))] + [JsonSerializable(typeof(STJBoolObject))] + [JsonSerializable(typeof(NotNullableSTJEnumObject))] + [JsonSerializable(typeof(STJEnumObject))] + [JsonSerializable(typeof(STJDecimalObject))] + [JsonSerializable(typeof(STJTimeObject))] + internal partial class TestSerializerContext : JsonSerializerContext + { + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs new file mode 100644 index 0000000..6f4a034 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketApiClient.cs @@ -0,0 +1,44 @@ +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Options; +using CryptoExchange.Net.Objects.Sockets; +using CryptoExchange.Net.SharedApis; +using Microsoft.Extensions.Logging; +using System; +using System.Net.Http; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketApiClient : SocketApiClient + { + public TestSocketApiClient(ILogger logger, TestSocketOptions options) + : base(logger, options.Environment.SocketClientAddress, options, options.ExchangeOptions) + { + } + + public TestSocketApiClient(ILogger logger, HttpClient httpClient, string baseAddress, TestSocketOptions options, SocketApiOptions apiOptions) + : base(logger, baseAddress, options, apiOptions) + { + } + + public override ISocketMessageHandler CreateMessageConverter(WebSocketMessageType messageType) => new TestSocketMessageHandler(); + protected internal override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(SerializerOptions.WithConverters(new TestSerializerContext())); + + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverDate = null) => + baseAsset + quoteAsset; + + protected override TestAuthenticationProvider CreateAuthenticationProvider(TestCredentials credentials) => + new TestAuthenticationProvider(credentials); + + public async Task> SubscribeToUpdatesAsync(Action> handler, bool subQuery, CancellationToken ct) + { + return await base.SubscribeAsync(new TestSubscription(_logger, handler, subQuery, false), ct); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs new file mode 100644 index 0000000..3b45729 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketClient.cs @@ -0,0 +1,26 @@ +using CryptoExchange.Net.Clients; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketClient : BaseSocketClient + { + public TestSocketApiClient ApiClient1 { get; set; } + public TestSocketApiClient ApiClient2 { get; set; } + + public TestSocketClient(Action? optionsDelegate = null) + : this(null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) + { + } + + public TestSocketClient(ILoggerFactory? loggerFactory, IOptions options) : base(loggerFactory, "Test") + { + Initialize(options.Value); + + ApiClient1 = AddApiClient(new TestSocketApiClient(_logger, options.Value)); + ApiClient2 = AddApiClient(new TestSocketApiClient(_logger, options.Value)); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs new file mode 100644 index 0000000..5a38b6c --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessage.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal record TestSocketMessage + { + [JsonPropertyName("id")] + public int Id { get; set; } + [JsonPropertyName("data")] + public string Data { get; set; } = string.Empty; + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs new file mode 100644 index 0000000..9acc48b --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketMessageHandler.cs @@ -0,0 +1,33 @@ +using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; +using CryptoExchange.Net.Converters.SystemTextJson; +using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; +using System.Text.Json; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketMessageHandler : JsonSocketMessageHandler + { + public override JsonSerializerOptions Options { get; } = SerializerOptions.WithConverters(new TestSerializerContext()); + + public TestSocketMessageHandler() + { + } + + protected override MessageTypeDefinition[] TypeEvaluators { get; } = [ + + new MessageTypeDefinition { + ForceIfFound = true, + Fields = [ + new PropertyFieldReference("id") + ], + TypeIdentifierCallback = (doc) => doc.FieldValue("id")! + }, + + new MessageTypeDefinition { + Fields = [ + ], + StaticIdentifier = "test" + }, + ]; + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs new file mode 100644 index 0000000..1193f26 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSocketOptions.cs @@ -0,0 +1,27 @@ +using CryptoExchange.Net.Objects.Options; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSocketOptions : SocketExchangeOptions + { + internal static TestSocketOptions Default { get; set; } = new TestSocketOptions() + { + Environment = TestEnvironment.Live, + AutoTimestamp = true + }; + + public TestSocketOptions() + { + Default?.Set(this); + } + + public SocketApiOptions ExchangeOptions { get; private set; } = new SocketApiOptions(); + + internal TestSocketOptions Set(TestSocketOptions targetOptions) + { + targetOptions = base.Set(targetOptions); + targetOptions.ExchangeOptions = ExchangeOptions.Set(targetOptions.ExchangeOptions); + return targetOptions; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs b/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs new file mode 100644 index 0000000..1ff2a4d --- /dev/null +++ b/CryptoExchange.Net.UnitTests/Implementations/TestSubscription.cs @@ -0,0 +1,50 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Sockets; +using CryptoExchange.Net.Sockets; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Text; + +namespace CryptoExchange.Net.UnitTests.Implementations +{ + internal class TestSubscription : Subscription + { + private readonly Action> _handler; + private bool _subQuery; + + public TestSubscription(ILogger logger, Action> handler, bool subQuery, bool authenticated) : base(logger, authenticated, true) + { + _handler = handler; + _subQuery = subQuery; + + MessageRouter = MessageRouter.CreateWithoutTopicFilter("test", HandleUpdate); + } + + protected override Query? GetSubQuery(SocketConnection connection) + { + if (!_subQuery) + return null; + + return new TestQuery(new TestSocketMessage { Id = 1, Data = "Sub" }, false); + } + + protected override Query? GetUnsubQuery(SocketConnection connection) + { + if (!_subQuery) + return null; + + return new TestQuery(new TestSocketMessage { Id = 2, Data = "Unsub" }, false); + } + + + private CallResult? HandleUpdate(SocketConnection connection, DateTime time, string? originalData, T data) + { + _handler(new DataEvent("Test", data, time, originalData)); + return CallResult.SuccessResult; + } + } +} diff --git a/CryptoExchange.Net.UnitTests/OptionsTests.cs b/CryptoExchange.Net.UnitTests/OptionsTests.cs index 59cb299..823e121 100644 --- a/CryptoExchange.Net.UnitTests/OptionsTests.cs +++ b/CryptoExchange.Net.UnitTests/OptionsTests.cs @@ -1,7 +1,7 @@ using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects.Options; -using CryptoExchange.Net.UnitTests.TestImplementations; +using CryptoExchange.Net.UnitTests.Implementations; using NUnit.Framework; using System; @@ -11,9 +11,9 @@ namespace CryptoExchange.Net.UnitTests public class OptionsTests { [TearDown] - public void Init() + public void TearDown() { - TestClientOptions.Default = new TestClientOptions + TestRestOptions.Default = new TestRestOptions { }; } @@ -31,9 +31,9 @@ namespace CryptoExchange.Net.UnitTests // assert Assert.Throws(typeof(ArgumentException), () => { - var opts = new RestExchangeOptions() + var opts = new TestRestOptions() { - ApiCredentials = new HMACCredential(key, secret) + ApiCredentials = new TestCredentials(key, secret) }; opts.ApiCredentials.Validate(); }); @@ -43,14 +43,14 @@ namespace CryptoExchange.Net.UnitTests public void TestBasicOptionsAreSet() { // arrange, act - var options = new TestClientOptions + var options = new TestRestOptions { - ApiCredentials = new HMACCredential("123", "456"), - ReceiveWindow = TimeSpan.FromSeconds(10) + ApiCredentials = new TestCredentials("123", "456"), + RequestTimeout = TimeSpan.FromSeconds(10) }; // assert - Assert.That(options.ReceiveWindow == TimeSpan.FromSeconds(10)); + Assert.That(options.RequestTimeout == TimeSpan.FromSeconds(10)); Assert.That(options.ApiCredentials.Key == "123"); Assert.That(options.ApiCredentials.Secret == "456"); } @@ -65,88 +65,88 @@ namespace CryptoExchange.Net.UnitTests Proxy = new ApiProxy("http://testproxy", 1234) }); - Assert.That(client.Api1.ClientOptions.Proxy, Is.Not.Null); - Assert.That(client.Api1.ClientOptions.Proxy.Host, Is.EqualTo("http://testproxy")); - Assert.That(client.Api1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); - Assert.That(client.Api1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); + Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null); + Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy")); + Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); + Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); } [Test] public void TestSetOptionsRestWithCredentials() { var client = new TestRestClient(); - client.SetOptions(new UpdateOptions + client.SetOptions(new UpdateOptions { - ApiCredentials = new HMACCredential("123", "456"), + ApiCredentials = new TestCredentials("123", "456"), RequestTimeout = TimeSpan.FromSeconds(2), Proxy = new ApiProxy("http://testproxy", 1234) }); - Assert.That(client.Api1.ApiCredentials, Is.Not.Null); - Assert.That(client.Api1.ApiCredentials.Key, Is.EqualTo("123")); - Assert.That(client.Api1.ClientOptions.Proxy, Is.Not.Null); - Assert.That(client.Api1.ClientOptions.Proxy.Host, Is.EqualTo("http://testproxy")); - Assert.That(client.Api1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); - Assert.That(client.Api1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); + Assert.That(client.ApiClient1.ApiCredentials, Is.Not.Null); + Assert.That(client.ApiClient1.ApiCredentials!.Key, Is.EqualTo("123")); + Assert.That(client.ApiClient1.ClientOptions.Proxy, Is.Not.Null); + Assert.That(client.ApiClient1.ClientOptions.Proxy!.Host, Is.EqualTo("http://testproxy")); + Assert.That(client.ApiClient1.ClientOptions.Proxy.Port, Is.EqualTo(1234)); + Assert.That(client.ApiClient1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); } [Test] public void TestWhenUpdatingSettingsExistingClientsAreNotAffected() { - TestClientOptions.Default = new TestClientOptions + TestRestOptions.Default = new TestRestOptions { - ApiCredentials = new HMACCredential("111", "222"), + ApiCredentials = new TestCredentials("111", "222"), RequestTimeout = TimeSpan.FromSeconds(1), }; var client1 = new TestRestClient(); Assert.That(client1.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(1))); - Assert.That(client1.ClientOptions.ApiCredentials.Key, Is.EqualTo("111")); + Assert.That(client1.ClientOptions.ApiCredentials!.Key, Is.EqualTo("111")); - TestClientOptions.Default.ApiCredentials = new HMACCredential("333", "444"); - TestClientOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2); + TestRestOptions.Default.ApiCredentials = new TestCredentials("333", "444"); + TestRestOptions.Default.RequestTimeout = TimeSpan.FromSeconds(2); var client2 = new TestRestClient(); Assert.That(client2.ClientOptions.RequestTimeout, Is.EqualTo(TimeSpan.FromSeconds(2))); - Assert.That(client2.ClientOptions.ApiCredentials.Key, Is.EqualTo("333")); + Assert.That(client2.ClientOptions.ApiCredentials!.Key, Is.EqualTo("333")); } } - public class TestClientOptions: RestExchangeOptions - { - /// - /// Default options for the futures client - /// - public static TestClientOptions Default { get; set; } = new TestClientOptions() - { - Environment = new TestEnvironment("test", "https://test.com") - }; + //public class TestClientOptions: RestExchangeOptions + //{ + // /// + // /// Default options for the futures client + // /// + // public static TestClientOptions Default { get; set; } = new TestClientOptions() + // { + // Environment = new TestEnvironment("test", "https://test.com") + // }; - /// - /// ctor - /// - public TestClientOptions() - { - Default?.Set(this); - } + // /// + // /// ctor + // /// + // public TestClientOptions() + // { + // Default?.Set(this); + // } - /// - /// The default receive window for requests - /// - public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); + // /// + // /// The default receive window for requests + // /// + // public TimeSpan ReceiveWindow { get; set; } = TimeSpan.FromSeconds(5); - public RestApiOptions Api1Options { get; private set; } = new RestApiOptions(); + // public RestApiOptions Api1Options { get; private set; } = new RestApiOptions(); - public RestApiOptions Api2Options { get; set; } = new RestApiOptions(); + // public RestApiOptions Api2Options { get; set; } = new RestApiOptions(); - internal TestClientOptions Set(TestClientOptions targetOptions) - { - targetOptions = base.Set(targetOptions); - targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options); - targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options); - return targetOptions; - } - } + // internal TestClientOptions Set(TestClientOptions targetOptions) + // { + // targetOptions = base.Set(targetOptions); + // targetOptions.Api1Options = Api1Options.Set(targetOptions.Api1Options); + // targetOptions.Api2Options = Api2Options.Set(targetOptions.Api2Options); + // return targetOptions; + // } + //} } diff --git a/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs b/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs new file mode 100644 index 0000000..2a3f83b --- /dev/null +++ b/CryptoExchange.Net.UnitTests/ParameterCollectionTests.cs @@ -0,0 +1,331 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.UnitTests.ConverterTests; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CryptoExchange.Net.UnitTests +{ + internal class ParameterCollectionTests + { + [Test] + public void AddingBasicValue_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.Add("test", "value"); + Assert.That(parameters["test"], Is.EqualTo("value")); + } + + [Test] + public void AddingBasicNullValue_ThrowsException() + { + var parameters = new ParameterCollection(); + Assert.Throws(() => parameters.Add("test", null!)); + } + + [Test] + public void AddingOptionalBasicValue_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptional("test", "value"); + Assert.That(parameters["test"], Is.EqualTo("value")); + } + + [Test] + public void AddingOptionalBasicNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptional("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingDecimalValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 0.1m); + Assert.That(parameters["test"], Is.EqualTo("0.1")); + } + + [Test] + public void AddingOptionalDecimalValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 0.1m); + Assert.That(parameters["test"], Is.EqualTo("0.1")); + } + + [Test] + public void AddingOptionalDecimalNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (decimal?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingIntValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 1); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalIntValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 1); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalIntNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (int?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingLongValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddString("test", 1L); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalLongValueAsString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", 1L); + Assert.That(parameters["test"], Is.EqualTo("1")); + } + + [Test] + public void AddingOptionalLongNullValueAsString_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalString("test", (long?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingMillisecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600000)); + } + + [Test] + public void AddingOptionalMillisecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMilliseconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600000)); + } + + [Test] + public void AddingOptionalMillisecondNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMilliseconds("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingMillisecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600000")); + } + + [Test] + public void AddingOptionalMillisecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMillisecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600000")); + } + + [Test] + public void AddingOptionalMillisecondStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalMillisecondsString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingSecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600)); + } + + [Test] + public void AddingOptionalSecondTimestamp_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSeconds("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo(1735689600)); + } + + [Test] + public void AddingSecondNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSeconds("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingSecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600")); + } + + [Test] + public void AddingOptionalSecondTimestampString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSecondsString("test", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.That(parameters["test"], Is.EqualTo("1735689600")); + } + + [Test] + public void AddingSecondStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalSecondsString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddEnum("test", TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo("2")); + } + + [Test] + public void AddingOptionalEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnum("test", (TestEnum?)TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo("2")); + } + + [Test] + public void AddingOptionalEnumNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnum("test", (TestEnum?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingEnumAsInt_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddEnumAsInt("test", TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo(2)); + } + + [Test] + public void AddingOptionalEnumAsInt_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnumAsInt("test", (TestEnum?)TestEnum.Two); + Assert.That(parameters["test"], Is.EqualTo(2)); + } + + [Test] + public void AddingOptionalEnumAsIntNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalEnumAsInt("test", (TestEnum?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingCommaSeparated_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddCommaSeparated("test", ["1", "2"]); + Assert.That(parameters["test"], Is.EqualTo("1,2")); + } + + [Test] + public void AddingOptionalCommaSeparated_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", ["1", "2"]); + Assert.That(parameters["test"], Is.EqualTo("1,2")); + } + + [Test] + public void AddingOptionalCommaSeparatedNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", (string[]?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingCommaSeparatedEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddCommaSeparated("test", [TestEnum.Two, TestEnum.One]); + Assert.That(parameters["test"], Is.EqualTo("2,1")); + } + + [Test] + public void AddingOptionalCommaSeparatedEnum_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", [TestEnum.Two, TestEnum.One]); + Assert.That(parameters["test"], Is.EqualTo("2,1")); + } + + [Test] + public void AddingOptionalCommaSeparatedEnumNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalCommaSeparated("test", (TestEnum[]?)null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + + [Test] + public void AddingBoolString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddBoolString("test", true); + Assert.That(parameters["test"], Is.EqualTo("true")); + } + + [Test] + public void AddingOptionalBoolString_SetValueCorrectly() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalBoolString("test", true); + Assert.That(parameters["test"], Is.EqualTo("true")); + } + + [Test] + public void AddingOptionalBoolStringNullValue_DoesntSetValue() + { + var parameters = new ParameterCollection(); + parameters.AddOptionalBoolString("test", null); + Assert.That(parameters.ContainsKey("test"), Is.False); + } + } +} diff --git a/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs b/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs index ec65f97..79d9cea 100644 --- a/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs +++ b/CryptoExchange.Net.UnitTests/SharedQuantityTests.cs @@ -1,6 +1,5 @@ using CryptoExchange.Net.SharedApis; using NUnit.Framework; -using System; namespace CryptoExchange.Net.UnitTests { diff --git a/CryptoExchange.Net.UnitTests/SocketClientTests.cs b/CryptoExchange.Net.UnitTests/SocketClientTests.cs deleted file mode 100644 index 97ce6a6..0000000 --- a/CryptoExchange.Net.UnitTests/SocketClientTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -//using CryptoExchange.Net.Objects; -//using CryptoExchange.Net.Objects.Sockets; -//using CryptoExchange.Net.Sockets; -//using CryptoExchange.Net.Testing.Implementations; -//using CryptoExchange.Net.UnitTests.TestImplementations; -//using CryptoExchange.Net.UnitTests.TestImplementations.Sockets; -//using Microsoft.Extensions.Logging; -//using Moq; -//using NUnit.Framework; -//using NUnit.Framework.Legacy; -//using System; -//using System.Collections.Generic; -//using System.Net.Sockets; -//using System.Text.Json; -//using System.Threading; -//using System.Threading.Tasks; - -//namespace CryptoExchange.Net.UnitTests -//{ -// [TestFixture] -// public class SocketClientTests -// { -// [TestCase] -// public void SettingOptions_Should_ResultInOptionsSet() -// { -// //arrange -// //act -// var client = new TestSocketClient(options => -// { -// options.SubOptions.ApiCredentials = new Authentication.ApiCredentials("1", "2"); -// options.SubOptions.MaxSocketConnections = 1; -// }); - -// //assert -// ClassicAssert.NotNull(client.SubClient.ApiOptions.ApiCredentials); -// Assert.That(1 == client.SubClient.ApiOptions.MaxSocketConnections); -// } - -// [TestCase(true)] -// [TestCase(false)] -// public void ConnectSocket_Should_ReturnConnectionResult(bool canConnect) -// { -// //arrange -// var client = new TestSocketClient(); -// var socket = client.CreateSocket(); -// socket.CanConnect = canConnect; - -// //act -// var connectResult = client.SubClient.ConnectSocketSub( -// new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// //assert -// Assert.That(connectResult.Success == canConnect); -// } - -// [TestCase] -// public void SocketMessages_Should_BeProcessedInDataHandlers() -// { -// // arrange -// var client = new TestSocketClient(options => { -// options.ReconnectInterval = TimeSpan.Zero; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var rstEvent = new ManualResetEvent(false); -// Dictionary result = null; - -// client.SubClient.ConnectSocketSub(sub); - -// var subObj = new TestSubscription>(Mock.Of(), (messageEvent) => -// { -// result = messageEvent.Data; -// rstEvent.Set(); -// }); -// sub.AddSubscription(subObj); - -// // act -// socket.InvokeMessage("{\"property\": \"123\", \"action\": \"update\", \"topic\": \"topic\"}"); -// rstEvent.WaitOne(1000); - -// // assert -// Assert.That(result["property"] == "123"); -// } - -// [TestCase(false)] -// [TestCase(true)] -// public void SocketMessages_Should_ContainOriginalDataIfEnabled(bool enabled) -// { -// // arrange -// var client = new TestSocketClient(options => -// { -// options.ReconnectInterval = TimeSpan.Zero; -// options.SubOptions.OutputOriginalData = enabled; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var rstEvent = new ManualResetEvent(false); -// string original = null; - -// client.SubClient.ConnectSocketSub(sub); -// var subObj = new TestSubscription>(Mock.Of(), (messageEvent) => -// { -// original = messageEvent.OriginalData; -// rstEvent.Set(); -// }); -// sub.AddSubscription(subObj); -// var msgToSend = JsonSerializer.Serialize(new { topic = "topic", action = "update", property = "123" }); - -// // act -// socket.InvokeMessage(msgToSend); -// rstEvent.WaitOne(1000); - -// // assert -// Assert.That(original == (enabled ? msgToSend : null)); -// } - -// [TestCase()] -// public void UnsubscribingStream_Should_CloseTheSocket() -// { -// // arrange -// var client = new TestSocketClient(options => -// { -// options.ReconnectInterval = TimeSpan.Zero; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// var sub = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// client.SubClient.ConnectSocketSub(sub); - -// var subscription = new TestSubscription>(Mock.Of(), (messageEvent) => { }); -// var ups = new UpdateSubscription(sub, subscription); -// sub.AddSubscription(subscription); - -// // act -// client.UnsubscribeAsync(ups).Wait(); - -// // assert -// Assert.That(socket.Connected == false); -// } - -// [TestCase()] -// public void UnsubscribingAll_Should_CloseAllSockets() -// { -// // arrange -// var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; }); -// var socket1 = client.CreateSocket(); -// var socket2 = client.CreateSocket(); -// socket1.CanConnect = true; -// socket2.CanConnect = true; -// var sub1 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket1), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// var sub2 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket2), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); -// client.SubClient.ConnectSocketSub(sub1); -// client.SubClient.ConnectSocketSub(sub2); -// var subscription1 = new TestSubscription>(Mock.Of(), (messageEvent) => { }); -// var subscription2 = new TestSubscription>(Mock.Of(), (messageEvent) => { }); - -// sub1.AddSubscription(subscription1); -// sub2.AddSubscription(subscription2); -// var ups1 = new UpdateSubscription(sub1, subscription1); -// var ups2 = new UpdateSubscription(sub2, subscription2); - -// // act -// client.UnsubscribeAllAsync().Wait(); - -// // assert -// Assert.That(socket1.Connected == false); -// Assert.That(socket2.Connected == false); -// } - -// [TestCase()] -// public void FailingToConnectSocket_Should_ReturnError() -// { -// // arrange -// var client = new TestSocketClient(options => { options.ReconnectInterval = TimeSpan.Zero; }); -// var socket = client.CreateSocket(); -// socket.CanConnect = false; -// var sub1 = new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, ""); - -// // act -// var connectResult = client.SubClient.ConnectSocketSub(sub1); - -// // assert -// ClassicAssert.IsFalse(connectResult.Success); -// } - -// [TestCase()] -// public async Task ErrorResponse_ShouldNot_ConfirmSubscription() -// { -// // arrange -// var channel = "trade_btcusd"; -// var client = new TestSocketClient(opt => -// { -// opt.OutputOriginalData = true; -// opt.SocketSubscriptionsCombineTarget = 1; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// // act -// var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default); -// socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "error" })); -// await sub; - -// // assert -// ClassicAssert.IsTrue(client.SubClient.TestSubscription.Status != SubscriptionStatus.Subscribed); -// } - -// [TestCase()] -// public async Task SuccessResponse_Should_ConfirmSubscription() -// { -// // arrange -// var channel = "trade_btcusd"; -// var client = new TestSocketClient(opt => -// { -// opt.OutputOriginalData = true; -// opt.SocketSubscriptionsCombineTarget = 1; -// }); -// var socket = client.CreateSocket(); -// socket.CanConnect = true; -// client.SubClient.ConnectSocketSub(new SocketConnection(new TraceLogger(), new TestWebsocketFactory(socket), new WebSocketParameters(new Uri("https://localhost/"), ReconnectPolicy.Disabled), client.SubClient, "")); - -// // act -// var sub = client.SubClient.SubscribeToSomethingAsync(channel, onUpdate => {}, ct: default); -// socket.InvokeMessage(JsonSerializer.Serialize(new { channel, action = "subscribe", status = "confirmed" })); -// await sub; - -// // assert -// Assert.That(client.SubClient.TestSubscription.Status == SubscriptionStatus.Subscribed); -// } -// } -//} diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs new file mode 100644 index 0000000..ce37219 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/QueryRouterTests.cs @@ -0,0 +1,235 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class QueryRouterTests + { + [Test] + public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType() + { + // arrange + var routes = new MessageRoute[] + { + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null) + }; + + var router = new QueryRouter(routes); + + // act + var type1Routes = router.GetRoutes("type1"); + var type2Routes = router.GetRoutes("type2"); + var missingRoutes = router.GetRoutes("missing"); + + // assert + Assert.That(type1Routes, Is.Not.Null); + Assert.That(type2Routes, Is.Not.Null); + Assert.That(missingRoutes, Is.Null); + + Assert.That(type1Routes, Is.TypeOf()); + Assert.That(type2Routes, Is.TypeOf()); + Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int))); + } + + [Test] + public void AddRoute_Should_SetMultipleReaders_WhenAnyRouteAllowsMultipleReaders() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + + // act + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => null)); + var beforeMultipleReaders = collection.MultipleReaders; + + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null, true)); + var afterMultipleReaders = collection.MultipleReaders; + + // assert + Assert.That(beforeMultipleReaders, Is.False); + Assert.That(afterMultipleReaders, Is.True); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + Assert.That(calls, Is.EqualTo(new[] { "no-topic" })); + } + + [Test] + public void Handle_Should_ReturnFalse_WhenNoRoutesMatch() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("other-topic", MessageRoute.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.Null); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" })); + } + + [Test] + public void Handle_Should_StopAfterFirstNonNullMatchingResult_WhenMultipleReadersIsFalse() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return expectedResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first" })); + } + + [Test] + public void Handle_Should_ContinueAfterNonNullMatchingResult_WhenMultipleReadersIsTrue() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return expectedResult; + }, true)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_ContinueUntilNonNullResult_WhenEarlierMatchingRoutesReturnNull() + { + // arrange + var calls = new List(); + var expectedResult = CallResult.SuccessResult; + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return expectedResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("third"); + return new CallResult(null); + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(expectedResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_ReturnHandledTrue_WhenMatchingRoutesReturnNull() + { + // arrange + var collection = new QueryRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.Null); + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs new file mode 100644 index 0000000..ccb6686 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/RoutingTableTests.cs @@ -0,0 +1,167 @@ +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using CryptoExchange.Net.Sockets.Interfaces; +using NUnit.Framework; +using System; +using System.Linq; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class RoutingTableTests + { + [Test] + public void Update_Should_CreateEntriesPerTypeIdentifier_WithCorrectDeserializationTypeAndHandlers() + { + // arrange + var processor1 = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null))); + + var processor2 = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null))); + + var table = new RoutingTable(); + + // act + table.Update(new IMessageProcessor[] { processor1, processor2 }); + + var type1Entry = table.GetRouteTableEntry("type1"); + var type2Entry = table.GetRouteTableEntry("type2"); + var missingEntry = table.GetRouteTableEntry("missing"); + + // assert + Assert.That(type1Entry, Is.Not.Null); + Assert.That(type2Entry, Is.Not.Null); + Assert.That(missingEntry, Is.Null); + + Assert.That(type1Entry!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type1Entry.IsStringOutput, Is.True); + Assert.That(type1Entry.Handlers, Has.Count.EqualTo(1)); + Assert.That(type1Entry.Handlers.Single(), Is.SameAs(processor1)); + + Assert.That(type2Entry!.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(type2Entry.IsStringOutput, Is.False); + Assert.That(type2Entry.Handlers, Has.Count.EqualTo(1)); + Assert.That(type2Entry.Handlers.Single(), Is.SameAs(processor2)); + } + + [Test] + public void Update_Should_AddMultipleProcessors_ForSameTypeIdentifier() + { + // arrange + var processor1 = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var processor2 = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null))); + + var table = new RoutingTable(); + + // act + table.Update(new IMessageProcessor[] { processor1, processor2 }); + var entry = table.GetRouteTableEntry("type1"); + + // assert + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(entry.Handlers, Has.Count.EqualTo(2)); + Assert.That(entry.Handlers, Does.Contain(processor1)); + Assert.That(entry.Handlers, Does.Contain(processor2)); + } + + [Test] + public void Update_Should_ReplacePreviousEntries() + { + // arrange + var initialProcessor = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var replacementProcessor = new TestMessageProcessor( + 2, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type2", (_, _, _, _) => null))); + + var table = new RoutingTable(); + table.Update(new IMessageProcessor[] { initialProcessor }); + + // act + table.Update(new IMessageProcessor[] { replacementProcessor }); + + var oldEntry = table.GetRouteTableEntry("type1"); + var newEntry = table.GetRouteTableEntry("type2"); + + // assert + Assert.That(oldEntry, Is.Null); + Assert.That(newEntry, Is.Not.Null); + Assert.That(newEntry!.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(newEntry.Handlers, Has.Count.EqualTo(1)); + Assert.That(newEntry.Handlers.Single(), Is.SameAs(replacementProcessor)); + } + + [Test] + public void Update_WithEmptyProcessors_Should_ClearEntries() + { + // arrange + var processor = new TestMessageProcessor( + 1, + MessageRouter.Create( + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null))); + + var table = new RoutingTable(); + table.Update(new IMessageProcessor[] { processor }); + + // act + table.Update(Array.Empty()); + + // assert + Assert.That(table.GetRouteTableEntry("type1"), Is.Null); + } + + [Test] + public void TypeRoutingCollection_Should_SetIsStringOutput_BasedOnDeserializationType() + { + // arrange & act + var stringCollection = new TypeRoutingCollection(typeof(string)); + var intCollection = new TypeRoutingCollection(typeof(int)); + + // assert + Assert.That(stringCollection.IsStringOutput, Is.True); + Assert.That(stringCollection.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(stringCollection.Handlers, Is.Empty); + + Assert.That(intCollection.IsStringOutput, Is.False); + Assert.That(intCollection.DeserializationType, Is.EqualTo(typeof(int))); + Assert.That(intCollection.Handlers, Is.Empty); + } + + private sealed class TestMessageProcessor : IMessageProcessor + { + public int Id { get; } + public MessageRouter MessageRouter { get; } + + public TestMessageProcessor(int id, MessageRouter messageRouter) + { + Id = id; + MessageRouter = messageRouter; + } + + public event Action? OnMessageRouterUpdated; + + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result) + { + return true; + } + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs new file mode 100644 index 0000000..8d68807 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionRouterTests.cs @@ -0,0 +1,160 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default.Routing; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class SubscriptionRouterTests + { + [Test] + public void BuildFromRoutes_Should_GroupRoutesByTypeIdentifier_AndSetDeserializationType() + { + // arrange + var routes = new MessageRoute[] + { + MessageRoute.CreateWithoutTopicFilter("type1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type1", "topic1", (_, _, _, _) => null), + MessageRoute.CreateWithTopicFilter("type2", "topic2", (_, _, _, _) => null) + }; + + var router = new SubscriptionRouter(routes); + + // act + var type1Routes = router.GetRoutes("type1"); + var type2Routes = router.GetRoutes("type2"); + var missingRoutes = router.GetRoutes("missing"); + + // assert + Assert.That(type1Routes, Is.Not.Null); + Assert.That(type2Routes, Is.Not.Null); + Assert.That(missingRoutes, Is.Null); + + Assert.That(type1Routes, Is.TypeOf()); + Assert.That(type2Routes, Is.TypeOf()); + Assert.That(type1Routes!.DeserializationType, Is.EqualTo(typeof(string))); + Assert.That(type2Routes!.DeserializationType, Is.EqualTo(typeof(int))); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "no-topic" })); + } + + [Test] + public void Handle_Should_ReturnFalse_WhenNoRoutesMatch() + { + // arrange + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("other-topic", MessageRoute.CreateWithTopicFilter("type", "other-topic", (_, _, _, _) => null)); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + } + + [Test] + public void Handle_Should_InvokeRoutesWithoutTopicFilter_AndMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute(null, MessageRoute.CreateWithoutTopicFilter("type", (_, _, _, _) => + { + calls.Add("no-topic"); + return null; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "no-topic", "topic" })); + } + + [Test] + public void Handle_Should_InvokeAllMatchingTopicRoutes() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("first"); + return CallResult.SuccessResult; + })); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("second"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle("topic", null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.True); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void Handle_Should_NotInvokeTopicRoutes_WhenTopicFilterIsNull() + { + // arrange + var calls = new List(); + var collection = new SubscriptionRouteCollection(typeof(string)); + collection.AddRoute("topic", MessageRoute.CreateWithTopicFilter("type", "topic", (_, _, _, _) => + { + calls.Add("topic"); + return null; + })); + collection.Build(); + + // act + var handled = collection.Handle(null, null!, DateTime.UtcNow, "original", "data", out var result); + + // assert + Assert.That(handled, Is.False); + Assert.That(result, Is.SameAs(CallResult.SuccessResult)); + Assert.That(calls, Is.Empty); + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs b/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs deleted file mode 100644 index ea991ee..0000000 --- a/CryptoExchange.Net.UnitTests/SystemTextJsonConverterTests.cs +++ /dev/null @@ -1,490 +0,0 @@ -using CryptoExchange.Net.Attributes; -using CryptoExchange.Net.Converters; -using CryptoExchange.Net.Converters.SystemTextJson; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.SharedApis; -using CryptoExchange.Net.Testing; -using Newtonsoft.Json.Linq; -using NUnit.Framework; -using System; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CryptoExchange.Net.UnitTests -{ - [TestFixture()] - public class SystemTextJsonConverterTests - { - [TestCase("2021-05-12")] - [TestCase("20210512")] - [TestCase("210512")] - [TestCase("1620777600.000")] - [TestCase("1620777600000")] - [TestCase("2021-05-12T00:00:00.000Z")] - [TestCase("2021-05-12T00:00:00.000000000Z")] - [TestCase("0.000000", true)] - [TestCase("0", true)] - [TestCase("", true)] - [TestCase(" ", true)] - public void TestDateTimeConverterString(string input, bool expectNull = false) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": \"{input}\" }}"); - Assert.That(output.Time == (expectNull ? null: new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); - } - - [TestCase(1620777600.000)] - [TestCase(1620777600000d)] - public void TestDateTimeConverterDouble(double input) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); - Assert.That(output.Time == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [TestCase(1620777600)] - [TestCase(1620777600000)] - [TestCase(1620777600000000)] - [TestCase(1620777600000000000)] - [TestCase(0, true)] - public void TestDateTimeConverterLong(long input, bool expectNull = false) - { - var output = JsonSerializer.Deserialize($"{{ \"time\": {input} }}"); - Assert.That(output.Time == (expectNull ? null : new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc))); - } - - [TestCase(1620777600)] - [TestCase(1620777600.000)] - public void TestDateTimeConverterFromSeconds(double input) - { - var output = DateTimeConverter.ConvertFromSeconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToSeconds() - { - var output = DateTimeConverter.ConvertToSeconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600); - } - - [TestCase(1620777600000)] - [TestCase(1620777600000.000)] - public void TestDateTimeConverterFromMilliseconds(double input) - { - var output = DateTimeConverter.ConvertFromMilliseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToMilliseconds() - { - var output = DateTimeConverter.ConvertToMilliseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000); - } - - [TestCase(1620777600000000)] - public void TestDateTimeConverterFromMicroseconds(long input) - { - var output = DateTimeConverter.ConvertFromMicroseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToMicroseconds() - { - var output = DateTimeConverter.ConvertToMicroseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000000); - } - - [TestCase(1620777600000000000)] - public void TestDateTimeConverterFromNanoseconds(long input) - { - var output = DateTimeConverter.ConvertFromNanoseconds(input); - Assert.That(output == new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - } - - [Test] - public void TestDateTimeConverterToNanoseconds() - { - var output = DateTimeConverter.ConvertToNanoseconds(new DateTime(2021, 05, 12, 0, 0, 0, DateTimeKind.Utc)); - Assert.That(output == 1620777600000000000); - } - - [TestCase()] - public void TestDateTimeConverterNull() - { - var output = JsonSerializer.Deserialize($"{{ \"time\": null }}"); - Assert.That(output.Time == null); - } - - [TestCase(TestEnum.One, "1")] - [TestCase(TestEnum.Two, "2")] - [TestCase(TestEnum.Three, "three")] - [TestCase(TestEnum.Four, "Four")] - [TestCase(null, null)] - public void TestEnumConverterNullableGetStringTests(TestEnum? value, string expected) - { - var output = EnumConverter.GetString(value); - Assert.That(output == expected); - } - - [TestCase(TestEnum.One, "1")] - [TestCase(TestEnum.Two, "2")] - [TestCase(TestEnum.Three, "three")] - [TestCase(TestEnum.Four, "Four")] - public void TestEnumConverterGetStringTests(TestEnum value, string expected) - { - var output = EnumConverter.GetString(value); - Assert.That(output == expected); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", null)] - [TestCase(null, null)] - public void TestEnumConverterNullableDeserializeTests(string value, TestEnum? expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", (TestEnum)(-9))] - [TestCase(null, (TestEnum)(-9))] - public void TestEnumConverterNotNullableDeserializeTests(string value, TestEnum expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}"); - Assert.That(output.Value == expected); - } - - [Test] - public void TestEnumConverterMapsUndefinedValueCorrectlyIfDefaultIsDefined() - { - var output = JsonSerializer.Deserialize($"\"TestUndefined\""); - Assert.That((int)output == -99); - } - - [TestCase("1", TestEnum.One)] - [TestCase("2", TestEnum.Two)] - [TestCase("3", TestEnum.Three)] - [TestCase("three", TestEnum.Three)] - [TestCase("Four", TestEnum.Four)] - [TestCase("four", TestEnum.Four)] - [TestCase("Four1", null)] - [TestCase(null, null)] - public void TestEnumConverterParseStringTests(string value, TestEnum? expected) - { - var result = EnumConverter.ParseString(value); - Assert.That(result == expected); - } - - [Test] - public void TestEnumConverterParseNullOnNonNullableOnlyLogsOnce() - { - LibraryHelpers.StaticLogger = new TraceLogger(); - var listener = new EnumValueTraceListener(); - Trace.Listeners.Add(listener); - EnumConverter.Reset(); - try - { - Assert.Throws(() => - { - var result = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new SerializationContext())); - }); - - Assert.DoesNotThrow(() => - { - var result2 = JsonSerializer.Deserialize("{\"Value\": null}", SerializerOptions.WithConverters(new SerializationContext())); - }); - } - finally - { - Trace.Listeners.Remove(listener); - } - } - - [TestCase("1", true)] - [TestCase("true", true)] - [TestCase("yes", true)] - [TestCase("y", true)] - [TestCase("on", true)] - [TestCase("-1", false)] - [TestCase("0", false)] - [TestCase("n", false)] - [TestCase("no", false)] - [TestCase("false", false)] - [TestCase("off", false)] - [TestCase("", null)] - public void TestBoolConverter(string value, bool? expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", true)] - [TestCase("true", true)] - [TestCase("yes", true)] - [TestCase("y", true)] - [TestCase("on", true)] - [TestCase("-1", false)] - [TestCase("0", false)] - [TestCase("n", false)] - [TestCase("no", false)] - [TestCase("false", false)] - [TestCase("off", false)] - [TestCase("", false)] - public void TestBoolConverterNotNullable(string value, bool expected) - { - var val = value == null ? "null" : $"\"{value}\""; - var output = JsonSerializer.Deserialize($"{{ \"Value\": {val} }}", SerializerOptions.WithConverters(new SerializationContext())); - Assert.That(output.Value == expected); - } - - [TestCase("1", 1)] - [TestCase("1.1", 1.1)] - [TestCase("-1.1", -1.1)] - [TestCase(null, null)] - [TestCase("", null)] - [TestCase("null", null)] - [TestCase("nan", null)] - [TestCase("1E+2", 100)] - [TestCase("1E-2", 0.01)] - [TestCase("Infinity", 999)] // 999 is workaround for not being able to specify decimal.MinValue - [TestCase("-Infinity", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - [TestCase("80228162514264337593543950335", 999)] // 999 is workaround for not being able to specify decimal.MaxValue - [TestCase("-80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - public void TestDecimalConverterString(string value, decimal? expected) - { - var result = JsonSerializer.Deserialize("{ \"test\": \""+ value + "\"}"); - Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MinValue : expected == 999 ? decimal.MaxValue: expected)); - } - - [TestCase("1", 1)] - [TestCase("1.1", 1.1)] - [TestCase("-1.1", -1.1)] - [TestCase("null", null)] - [TestCase("1E+2", 100)] - [TestCase("1E-2", 0.01)] - [TestCase("80228162514264337593543950335", -999)] // -999 is workaround for not being able to specify decimal.MaxValue - public void TestDecimalConverterNumber(string value, decimal? expected) - { - var result = JsonSerializer.Deserialize("{ \"test\": " + value + "}"); - Assert.That(result.Test, Is.EqualTo(expected == -999 ? decimal.MaxValue : expected)); - } - - [Test()] - public void TestArrayConverter() - { - var data = new Test() - { - Prop1 = 2, - Prop2 = null, - Prop3 = "123", - Prop3Again = "123", - Prop4 = null, - Prop5 = new Test2 - { - Prop21 = 3, - Prop22 = "456" - }, - Prop6 = new Test3 - { - Prop31 = 4, - Prop32 = "789" - }, - Prop7 = TestEnum.Two, - TestInternal = new Test - { - Prop1 = 10 - }, - Prop8 = new Test3 - { - Prop31 = 5, - Prop32 = "101" - }, - }; - - var options = new JsonSerializerOptions() - { - TypeInfoResolver = new SerializationContext() - }; - var serialized = JsonSerializer.Serialize(data); - var deserialized = JsonSerializer.Deserialize(serialized); - - Assert.That(deserialized.Prop1, Is.EqualTo(2)); - Assert.That(deserialized.Prop2, Is.Null); - Assert.That(deserialized.Prop3, Is.EqualTo("123")); - Assert.That(deserialized.Prop3Again, Is.EqualTo("123")); - Assert.That(deserialized.Prop4, Is.Null); - Assert.That(deserialized.Prop5.Prop21, Is.EqualTo(3)); - Assert.That(deserialized.Prop5.Prop22, Is.EqualTo("456")); - Assert.That(deserialized.Prop6.Prop31, Is.EqualTo(4)); - Assert.That(deserialized.Prop6.Prop32, Is.EqualTo("789")); - Assert.That(deserialized.Prop7, Is.EqualTo(TestEnum.Two)); - Assert.That(deserialized.TestInternal.Prop1, Is.EqualTo(10)); - Assert.That(deserialized.Prop8.Prop31, Is.EqualTo(5)); - Assert.That(deserialized.Prop8.Prop32, Is.EqualTo("101")); - } - - [TestCase(TradingMode.Spot, "ETH", "USDT", null)] - [TestCase(TradingMode.PerpetualLinear, "ETH", "USDT", null)] - [TestCase(TradingMode.DeliveryLinear, "ETH", "USDT", 1748432430)] - public void TestSharedSymbolConversion(TradingMode tradingMode, string baseAsset, string quoteAsset, int? deliverTime) - { - DateTime? time = deliverTime == null ? null : DateTimeConverter.ParseFromDouble(deliverTime.Value); - var symbol = new SharedSymbol(tradingMode, baseAsset, quoteAsset, time); - - var serialized = JsonSerializer.Serialize(symbol); - var restored = JsonSerializer.Deserialize(serialized); - - Assert.That(restored.TradingMode, Is.EqualTo(symbol.TradingMode)); - Assert.That(restored.BaseAsset, Is.EqualTo(symbol.BaseAsset)); - Assert.That(restored.QuoteAsset, Is.EqualTo(symbol.QuoteAsset)); - Assert.That(restored.DeliverTime, Is.EqualTo(symbol.DeliverTime)); - } - - [TestCase(0.1, null, null)] - [TestCase(0.1, 0.1, null)] - [TestCase(0.1, 0.1, 0.1)] - [TestCase(null, 0.1, null)] - [TestCase(null, 0.1, 0.1)] - public void TestSharedQuantityConversion(double? baseQuantity, double? quoteQuantity, double? contractQuantity) - { - var symbol = new SharedOrderQuantity((decimal?)baseQuantity, (decimal?)quoteQuantity, (decimal?)contractQuantity); - - var serialized = JsonSerializer.Serialize(symbol); - var restored = JsonSerializer.Deserialize(serialized); - - Assert.That(restored.QuantityInBaseAsset, Is.EqualTo(symbol.QuantityInBaseAsset)); - Assert.That(restored.QuantityInQuoteAsset, Is.EqualTo(symbol.QuantityInQuoteAsset)); - Assert.That(restored.QuantityInContracts, Is.EqualTo(symbol.QuantityInContracts)); - } - } - - public class STJDecimalObject - { - [JsonConverter(typeof(DecimalConverter))] - [JsonPropertyName("test")] - public decimal? Test { get; set; } - } - - public class STJTimeObject - { - [JsonConverter(typeof(DateTimeConverter))] - [JsonPropertyName("time")] - public DateTime? Time { get; set; } - } - - public class STJEnumObject - { - public TestEnum? Value { get; set; } - } - - public class NotNullableSTJEnumObject - { - public TestEnum Value { get; set; } - } - - public class STJBoolObject - { - public bool? Value { get; set; } - } - - public class NotNullableSTJBoolObject - { - public bool Value { get; set; } - } - - [JsonConverter(typeof(ArrayConverter))] - record Test - { - [ArrayProperty(0)] - public int Prop1 { get; set; } - [ArrayProperty(1)] - public int? Prop2 { get; set; } - [ArrayProperty(2)] - public string Prop3 { get; set; } - [ArrayProperty(2)] - public string Prop3Again { get; set; } - [ArrayProperty(3)] - public string Prop4 { get; set; } - [ArrayProperty(4)] - public Test2 Prop5 { get; set; } - [ArrayProperty(5)] - public Test3 Prop6 { get; set; } - [ArrayProperty(6), JsonConverter(typeof(EnumConverter))] - public TestEnum? Prop7 { get; set; } - [ArrayProperty(7)] - public Test TestInternal { get; set; } - [ArrayProperty(8), JsonConversion] - public Test3 Prop8 { get; set; } - } - - [JsonConverter(typeof(ArrayConverter))] - record Test2 - { - [ArrayProperty(0)] - public int Prop21 { get; set; } - [ArrayProperty(1)] - public string Prop22 { get; set; } - } - - record Test3 - { - [JsonPropertyName("prop31")] - public int Prop31 { get; set; } - [JsonPropertyName("prop32")] - public string Prop32 { get; set; } - } - - [JsonConverter(typeof(EnumConverter))] - public enum TestEnum - { - [Map("1")] - One, - [Map("2")] - Two, - [Map("three", "3")] - Three, - Four - } - - [JsonConverter(typeof(EnumConverter))] - public enum TestEnum2 - { - [Map("-9")] - Minus9 = -9, - [Map("1")] - One, - [Map("2")] - Two, - [Map("three", "3")] - Three, - Four - } - - [JsonSerializable(typeof(Test))] - [JsonSerializable(typeof(Test2))] - [JsonSerializable(typeof(Test3))] - [JsonSerializable(typeof(NotNullableSTJBoolObject))] - [JsonSerializable(typeof(STJBoolObject))] - [JsonSerializable(typeof(NotNullableSTJEnumObject))] - [JsonSerializable(typeof(STJEnumObject))] - [JsonSerializable(typeof(STJDecimalObject))] - [JsonSerializable(typeof(STJTimeObject))] - internal partial class SerializationContext : JsonSerializerContext - { - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs deleted file mode 100644 index e7a6496..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestBaseClient.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using CryptoExchange.Net.Authentication; -using CryptoExchange.Net.Clients; -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; -using CryptoExchange.Net.Converters.SystemTextJson; -using CryptoExchange.Net.Interfaces; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Objects.Errors; -using CryptoExchange.Net.Objects.Options; -using CryptoExchange.Net.SharedApis; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace CryptoExchange.Net.UnitTests -{ - public class TestBaseClient: BaseClient - { - public TestSubClient SubClient { get; } - - public TestBaseClient(): base(null, "Test") - { - var options = new TestClientOptions(); - _logger = NullLogger.Instance; - Initialize(options); - SubClient = AddApiClient(new TestSubClient(options, new RestApiOptions())); - } - - public TestBaseClient(TestClientOptions exchangeOptions) : base(null, "Test") - { - _logger = NullLogger.Instance; - Initialize(exchangeOptions); - SubClient = AddApiClient(new TestSubClient(exchangeOptions, new RestApiOptions())); - } - - public void Log(LogLevel verbosity, string data) - { - _logger.Log(verbosity, data); - } - } - - public class TestSubClient : RestApiClient - { - protected override IRestMessageHandler MessageHandler => throw new NotImplementedException(); - - public TestSubClient(RestExchangeOptions options, RestApiOptions apiOptions) : base(new TraceLogger(), null, "https://localhost:123", options, apiOptions) - { - } - - public CallResult Deserialize(string data) - { - return new CallResult(JsonSerializer.Deserialize(data)); - } - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) => throw new NotImplementedException(); - protected override Task> GetServerTimestampAsync() => throw new NotImplementedException(); - } - - public class TestAuthProvider : AuthenticationProvider - { - public TestAuthProvider(HMACCredential credentials) : base(credentials, credentials) - { - } - - public override void ProcessRequest(RestApiClient apiClient, RestRequestConfiguration requestConfig) - { - } - - public string GetKey() => Credential.Key; - public string GetSecret() => Credential.Secret; - } - - public class TestEnvironment : TradeEnvironment - { - public string TestAddress { get; } - - public TestEnvironment(string name, string url) : base(name) - { - TestAddress = url; - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs deleted file mode 100644 index 9e95043..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestHelpers.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - public class TestHelpers - { - [ExcludeFromCodeCoverage] - public static bool AreEqual(T self, T to, params string[] ignore) where T : class - { - if (self != null && to != null) - { - var type = self.GetType(); - var ignoreList = new List(ignore); - foreach (var pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (ignoreList.Contains(pi.Name)) - { - continue; - } - - var selfValue = type.GetProperty(pi.Name).GetValue(self, null); - var toValue = type.GetProperty(pi.Name).GetValue(to, null); - - if (pi.PropertyType.IsClass && !pi.PropertyType.Module.ScopeName.Equals("System.Private.CoreLib.dll")) - { - // Check of "CommonLanguageRuntimeLibrary" is needed because string is also a class - if (AreEqual(selfValue, toValue, ignore)) - { - continue; - } - - return false; - } - - if (selfValue != toValue && (selfValue == null || !selfValue.Equals(toValue))) - { - return false; - } - } - - return true; - } - - return self == to; - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs deleted file mode 100644 index 487e825..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestClient.cs +++ /dev/null @@ -1,213 +0,0 @@ -using CryptoExchange.Net.Interfaces; -using CryptoExchange.Net.Objects; -using Moq; -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using CryptoExchange.Net.Authentication; -using System.Collections.Generic; -using Microsoft.Extensions.Logging; -using CryptoExchange.Net.Clients; -using Microsoft.Extensions.Options; -using System.Linq; -using CryptoExchange.Net.Converters.SystemTextJson; -using System.Text.Json.Serialization; -using System.Net.Http.Headers; -using CryptoExchange.Net.SharedApis; -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - public class TestRestClient: BaseRestClient - { - public TestRestApi1Client Api1 { get; } - public TestRestApi2Client Api2 { get; } - - public TestRestClient(Action optionsDelegate = null) - : this(null, null, Options.Create(ApplyOptionsDelegate(optionsDelegate))) - { - } - - public TestRestClient(HttpClient httpClient, ILoggerFactory loggerFactory, IOptions options) : base(loggerFactory, "Test") - { - Initialize(options.Value); - - Api1 = AddApiClient(new TestRestApi1Client(options.Value)); - Api2 = AddApiClient(new TestRestApi2Client(options.Value)); - } - - public void SetResponse(string responseData, out IRequest requestObj) - { - var expectedBytes = Encoding.UTF8.GetBytes(responseData); - var responseStream = new MemoryStream(); - responseStream.Write(expectedBytes, 0, expectedBytes.Length); - responseStream.Seek(0, SeekOrigin.Begin); - - var response = new Mock(); - response.Setup(c => c.IsSuccessStatusCode).Returns(true); - response.Setup(c => c.GetResponseStreamAsync(It.IsAny())).Returns(Task.FromResult((Stream)responseStream)); - - var headers = new HttpRequestMessage().Headers; - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Returns(Task.FromResult(response.Object)); - request.Setup(c => c.SetContent(It.IsAny(), It.IsAny(), It.IsAny())).Callback(new Action((content, encoding, type) => { request.Setup(r => r.Content).Returns(content); })); - request.Setup(c => c.AddHeader(It.IsAny(), It.IsAny())).Callback((key, val) => headers.Add(key, new string[] { val })); - request.Setup(c => c.GetHeaders()).Returns(() => headers); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => - { - request.Setup(a => a.Uri).Returns(uri); - request.Setup(a => a.Method).Returns(method); - }) - .Returns(request.Object); - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => - { - request.Setup(a => a.Uri).Returns(uri); - request.Setup(a => a.Method).Returns(method); - }) - .Returns(request.Object); - requestObj = request.Object; - } - - public void SetErrorWithoutResponse(HttpStatusCode code, string message) - { - var we = new HttpRequestException(); - typeof(HttpRequestException).GetField("_message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(we, message); - - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetHeaders()).Returns(new HttpRequestMessage().Headers); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Throws(we); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(request.Object); - - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(request.Object); - } - - public void SetErrorWithResponse(string responseData, HttpStatusCode code) - { - var expectedBytes = Encoding.UTF8.GetBytes(responseData); - var responseStream = new MemoryStream(); - responseStream.Write(expectedBytes, 0, expectedBytes.Length); - responseStream.Seek(0, SeekOrigin.Begin); - - var response = new Mock(); - response.Setup(c => c.IsSuccessStatusCode).Returns(false); - response.Setup(c => c.GetResponseStreamAsync(It.IsAny())).Returns(Task.FromResult((Stream)responseStream)); - - var headers = new List>(); - var request = new Mock(); - request.Setup(c => c.Uri).Returns(new Uri("http://www.test.com")); - request.Setup(c => c.GetResponseAsync(It.IsAny())).Returns(Task.FromResult(response.Object)); - request.Setup(c => c.AddHeader(It.IsAny(), It.IsAny())).Callback((key, val) => headers.Add(new KeyValuePair(key, new string[] { val }))); - request.Setup(c => c.GetHeaders()).Returns(new HttpRequestMessage().Headers); - - var factory = Mock.Get(Api1.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => request.Setup(a => a.Uri).Returns(uri)) - .Returns(request.Object); - - factory = Mock.Get(Api2.RequestFactory); - factory.Setup(c => c.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((version, method, uri, id) => request.Setup(a => a.Uri).Returns(uri)) - .Returns(request.Object); - } - } - - public class TestRestApi1Client : RestApiClient - { - protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); - - public TestRestApi1Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api1Options) - { - RequestFactory = new Mock().Object; - } - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - - public async Task> Request(CancellationToken ct = default) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct); - } - - public async Task> RequestWithParams(HttpMethod method, ParameterCollection parameters, Dictionary headers) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", method) { Weight = 0 }, parameters, default, additionalHeaders: headers); - } - - public void SetParameterPosition(HttpMethod method, HttpMethodParameterPosition position) - { - ParameterPositions[method] = position; - } - - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) - => new TestAuthProvider(credentials); - - protected override Task> GetServerTimestampAsync() - { - throw new NotImplementedException(); - } - } - - public class TestRestApi2Client : RestApiClient - { - protected override IRestMessageHandler MessageHandler { get; } = new TestRestMessageHandler(); - - public TestRestApi2Client(TestClientOptions options) : base(new TraceLogger(), null, "https://localhost:123", options, options.Api2Options) - { - RequestFactory = new Mock().Object; - } - - protected override IMessageSerializer CreateSerializer() => new SystemTextJsonMessageSerializer(new System.Text.Json.JsonSerializerOptions()); - - /// - public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode futuresType, DateTime? deliverDate = null) => $"{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; - - public async Task> Request(CancellationToken ct = default) where T : class - { - return await SendAsync("http://www.test.com", new RequestDefinition("/", HttpMethod.Get) { Weight = 0 }, null, ct); - } - - protected override TestAuthProvider CreateAuthenticationProvider(HMACCredential credentials) - => new TestAuthProvider(credentials); - - protected override Task> GetServerTimestampAsync() - { - throw new NotImplementedException(); - } - - } - - public class TestError - { - [JsonPropertyName("errorCode")] - public int ErrorCode { get; set; } - [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } - } - - public class ParseErrorTestRestClient: TestRestClient - { - public ParseErrorTestRestClient() { } - - } -} diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs b/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs deleted file mode 100644 index bf4be5b..0000000 --- a/CryptoExchange.Net.UnitTests/TestImplementations/TestRestMessageHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; -using CryptoExchange.Net.Converters.SystemTextJson.MessageHandlers; -using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Objects.Errors; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace CryptoExchange.Net.UnitTests.TestImplementations -{ - internal class TestRestMessageHandler : JsonRestMessageHandler - { - private ErrorMapping _errorMapping = new ErrorMapping([]); - public override JsonSerializerOptions Options => new JsonSerializerOptions(); - - public override async ValueTask ParseErrorResponse(int httpStatusCode, HttpResponseHeaders responseHeaders, Stream responseStream) - { - var result = await GetJsonDocument(responseStream).ConfigureAwait(false); - if (result.Item1 != null) - return result.Item1; - - var errorData = result.Item2.Deserialize(); - return new ServerError(errorData.ErrorCode, _errorMapping.GetErrorInfo(errorData.ErrorCode.ToString(), errorData.ErrorMessage)); - } - } -} diff --git a/CryptoExchange.Net.UnitTests/TestSerializerContext.cs b/CryptoExchange.Net.UnitTests/TestSerializerContext.cs deleted file mode 100644 index ce23994..0000000 --- a/CryptoExchange.Net.UnitTests/TestSerializerContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CryptoExchange.Net.UnitTests.TestImplementations; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace CryptoExchange.Net.UnitTests -{ - [JsonSerializable(typeof(string))] - [JsonSerializable(typeof(int))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(TestObject))] - internal partial class TestSerializerContext : JsonSerializerContext - { - } -} diff --git a/CryptoExchange.Net.UnitTests/UriSerializationTests.cs b/CryptoExchange.Net.UnitTests/UriSerializationTests.cs new file mode 100644 index 0000000..8ff70d4 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/UriSerializationTests.cs @@ -0,0 +1,105 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; +using CryptoExchange.Net; +using CryptoExchange.Net.Objects; + +namespace CryptoExchange.Net.UnitTests +{ + internal class UriSerializationTests + { + [Test] + public void CreateParamString_SerializesBasicValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", "1" }, + { "b", 2 }, + { "c", true } + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a=1&b=2&c=True")); + } + + [Test] + public void CreateParamString_SerializesArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a[]=1&a[]=2")); + } + + [Test] + public void CreateParamStringEncoded_SerializesArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.Array); + + Assert.That(parameterString, Is.EqualTo("a[]=1%2B2&a[]=2%2B3")); + } + + [Test] + public void CreateParamString_SerializesJsonArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.JsonArray); + + Assert.That(parameterString, Is.EqualTo("a=[1,2]")); + } + + [Test] + public void CreateParamStringEncoded_SerializesJsonArrayValuesCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.JsonArray); + + Assert.That(parameterString, Is.EqualTo("a=[1%2B2,2%2B3]")); + } + + [Test] + public void CreateParamString_SerializesMultipleValuesArrayCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1", "2" } }, + }; + + var parameterString = parameters.CreateParamString(false, ArrayParametersSerialization.MultipleValues); + + Assert.That(parameterString, Is.EqualTo("a=1&a=2")); + } + + [Test] + public void CreateParamStringEncoded_SerializesMultipleValuesArrayCorrectly() + { + var parameters = new Dictionary() + { + { "a", new [] { "1+2", "2+3" } }, + }; + + var parameterString = parameters.CreateParamString(true, ArrayParametersSerialization.MultipleValues); + + Assert.That(parameterString, Is.EqualTo("a=1%2B2&a=2%2B3")); + } + } +} diff --git a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs index 4586a7a..fa6ea5d 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs @@ -82,14 +82,23 @@ namespace CryptoExchange.Net.Converters.SystemTextJson #if NET8_0_OR_GREATER private static FrozenSet? _mappingToEnum = null; private static FrozenDictionary? _mappingToString = null; + + private static bool RunOptimistic => true; #else private static List? _mappingToEnum = null; private static Dictionary? _mappingToString = null; + + // In NetStandard the `ValueTextEquals` method used is slower than just string comparing + // so only bother in newer frameworks + private static bool RunOptimistic => false; #endif private NullableEnumConverter? _nullableEnumConverter = null; + private static Type _enumType = typeof(T); private static T? _undefinedEnumValue; + private static bool _hasFlagsAttribute = _enumType.IsDefined(typeof(FlagsAttribute)); private static ConcurrentBag _unknownValuesWarned = new ConcurrentBag(); + private static ConcurrentBag _notOptimalValuesWarned = new ConcurrentBag(); internal class NullableEnumConverter : JsonConverter { @@ -153,10 +162,17 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private T? ReadNullable(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, out bool isEmptyStringOrNull) { isEmptyStringOrNull = false; - var enumType = typeof(T); if (_mappingToEnum == null) CreateMapping(); + if (RunOptimistic) + { + var resultOptimistic = GetValueOptimistic(ref reader); + if (resultOptimistic != null) + return resultOptimistic.Value; + } + + var isNumber = reader.TokenType == JsonTokenType.Number; var stringValue = reader.TokenType switch { JsonTokenType.String => reader.GetString(), @@ -173,8 +189,9 @@ namespace CryptoExchange.Net.Converters.SystemTextJson return null; } - if (!GetValue(enumType, stringValue, out var result)) + if (!GetValue(stringValue, out var result)) { + // Note: checking this here and before the GetValue seems redundant but it allows enum mapping for empty strings if (string.IsNullOrWhiteSpace(stringValue)) { isEmptyStringOrNull = true; @@ -185,13 +202,22 @@ namespace CryptoExchange.Net.Converters.SystemTextJson if (!_unknownValuesWarned.Contains(stringValue)) { _unknownValuesWarned.Add(stringValue!); - LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should added please open an issue on the Github repo"); + LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo"); } } return null; } + if (RunOptimistic && !isNumber) + { + if (!_notOptimalValuesWarned.Contains(stringValue)) + { + _notOptimalValuesWarned.Add(stringValue!); + LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]"); + } + } + return result; } @@ -202,18 +228,40 @@ namespace CryptoExchange.Net.Converters.SystemTextJson writer.WriteStringValue(stringValue); } - private static bool GetValue(Type objectType, string value, out T? result) + /// + /// Try to get the enum value based on the string value using the Utf8JsonReader's ValueTextEquals method. + /// This is an optimization to avoid string allocations when possible, but can only match case sensitively + /// + private static T? GetValueOptimistic(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.String) + return null; + + foreach (var item in _mappingToEnum!) + { + if (reader.ValueTextEquals(item.StringValue)) + return item.Value; + } + + return null; + } + + private static bool GetValue(string value, out T? result) { if (_mappingToEnum != null) { EnumMapping? mapping = null; - // Try match on full equals - foreach (var item in _mappingToEnum) + // If we tried the optimistic path first we already know its not case match + if (!RunOptimistic) { - if (item.StringValue.Equals(value, StringComparison.Ordinal)) + // Try match on full equals + foreach (var item in _mappingToEnum) { - mapping = item; - break; + if (item.StringValue.Equals(value, StringComparison.Ordinal)) + { + mapping = item; + break; + } } } @@ -237,10 +285,10 @@ namespace CryptoExchange.Net.Converters.SystemTextJson } } - if (objectType.IsDefined(typeof(FlagsAttribute))) + if (_hasFlagsAttribute) { var intValue = int.Parse(value); - result = (T)Enum.ToObject(objectType, intValue); + result = (T)Enum.ToObject(_enumType, intValue); return true; } @@ -262,8 +310,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { // If no explicit mapping is found try to parse string - result = (T)Enum.Parse(objectType, value, true); - if (!Enum.IsDefined(objectType, result)) +#if NET8_0_OR_GREATER + result = Enum.Parse(value, true); +#else + result = (T)Enum.Parse(_enumType, value, true); +#endif + if (!Enum.IsDefined(_enumType, result)) { result = default; return false; @@ -280,11 +332,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private static void CreateMapping() { - var mappingToEnum = new List(); - var mappingToString = new Dictionary(); + var mappingStringToEnum = new List(); + var mappingEnumToString = new Dictionary(); - var enumType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - var enumMembers = enumType.GetFields(); +#pragma warning disable IL2080 + var enumMembers = _enumType.GetFields(); +#pragma warning restore IL2080 foreach (var member in enumMembers) { var maps = member.GetCustomAttributes(typeof(MapAttribute), false); @@ -292,23 +345,29 @@ namespace CryptoExchange.Net.Converters.SystemTextJson { foreach (var value in attribute.Values) { - var enumVal = (T)Enum.Parse(enumType, member.Name); - mappingToEnum.Add(new EnumMapping(enumVal, value)); - if (!mappingToString.ContainsKey(enumVal)) - mappingToString.Add(enumVal, value); +#if NET8_0_OR_GREATER + var enumVal = Enum.Parse(member.Name); +#else + var enumVal = (T)Enum.Parse(_enumType, member.Name); +#endif + + mappingStringToEnum.Add(new EnumMapping(enumVal, value)); + if (!mappingEnumToString.ContainsKey(enumVal)) + mappingEnumToString.Add(enumVal, value); } } } #if NET8_0_OR_GREATER - _mappingToEnum = mappingToEnum.ToFrozenSet(); - _mappingToString = mappingToString.ToFrozenDictionary(); + _mappingToEnum = mappingStringToEnum.ToFrozenSet(); + _mappingToString = mappingEnumToString.ToFrozenDictionary(); #else - _mappingToEnum = mappingToEnum; - _mappingToString = mappingToString; + _mappingToEnum = mappingStringToEnum; + _mappingToString = mappingEnumToString; #endif } + // For testing purposes only, allows resetting the static mapping and warnings internal static void Reset() { _undefinedEnumValue = null; @@ -336,7 +395,6 @@ namespace CryptoExchange.Net.Converters.SystemTextJson /// public static T? ParseString(string value) { - var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); if (_mappingToEnum == null) CreateMapping(); @@ -369,8 +427,11 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { - // If no explicit mapping is found try to parse string - return (T)Enum.Parse(type, value, true); +#if NET8_0_OR_GREATER + return Enum.Parse(value, true); +#else + return (T)Enum.Parse(_enumType, value, true); +#endif } catch (Exception) { diff --git a/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs index 53a77ee..2789c23 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/ObjectStringConverter.cs @@ -24,7 +24,7 @@ namespace CryptoExchange.Net.Converters.SystemTextJson if (string.IsNullOrEmpty(value)) return default; - return (T?)JsonDocument.Parse(value!).Deserialize(typeof(T), options); + return JsonDocument.Parse(value!).Deserialize(options); } /// diff --git a/CryptoExchange.Net/ExtensionMethods.cs b/CryptoExchange.Net/ExtensionMethods.cs index c8d1693..452dce8 100644 --- a/CryptoExchange.Net/ExtensionMethods.cs +++ b/CryptoExchange.Net/ExtensionMethods.cs @@ -107,7 +107,7 @@ namespace CryptoExchange.Net } else { - uriString.Append('['); + uriString.Append($"{parameter.Key}=["); var firstArrayEntry = true; foreach (var entry in (Array)parameter.Value) { diff --git a/CryptoExchange.Net/Objects/ParameterCollection.cs b/CryptoExchange.Net/Objects/ParameterCollection.cs index d49f8fa..723fb6f 100644 --- a/CryptoExchange.Net/Objects/ParameterCollection.cs +++ b/CryptoExchange.Net/Objects/ParameterCollection.cs @@ -278,6 +278,35 @@ namespace CryptoExchange.Net.Objects base.Add(key, string.Join(",", values)); } + /// + /// Add key as comma separated values + /// +#if NET5_0_OR_GREATER + public void AddCommaSeparated<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicFields)] T>(string key, IEnumerable values) +#else + public void AddCommaSeparated(string key, IEnumerable values) +#endif + where T : struct, Enum + { + base.Add(key, string.Join(",", values.Select(x => EnumConverter.GetString(x)))); + } + + /// + /// Add key as comma separated values if there are values provided + /// +#if NET5_0_OR_GREATER + public void AddOptionalCommaSeparated<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicFields)] T>(string key, IEnumerable? values) +#else + public void AddOptionalCommaSeparated(string key, IEnumerable? values) +#endif + where T : struct, Enum + { + if (values == null || !values.Any()) + return; + + base.Add(key, string.Join(",", values.Select(x => EnumConverter.GetString(x)))); + } + /// /// Add key as boolean lower case value /// diff --git a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs index 074c6a0..9abcea7 100644 --- a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs +++ b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetOrderBookOptions.cs @@ -45,7 +45,7 @@ namespace CryptoExchange.Net.SharedApis public override Error? ValidateRequest(string exchange, GetOrderBookRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes) { if (request.Limit == null) - return null; + return base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes); if (MaxLimit.HasValue && request.Limit.Value > MaxLimit) return ArgumentError.Invalid(nameof(GetOrderBookRequest.Limit), $"Max limit is {MaxLimit}"); diff --git a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs index 815aa29..ef32f6b 100644 --- a/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs +++ b/CryptoExchange.Net/SharedApis/Models/Options/Endpoints/GetRecentTradesOptions.cs @@ -22,8 +22,12 @@ namespace CryptoExchange.Net.SharedApis } /// - public Error? Validate(GetRecentTradesRequest request) + public override Error? ValidateRequest(string exchange, GetRecentTradesRequest request, TradingMode? tradingMode, TradingMode[] supportedApiTypes) { + var baseError = base.ValidateRequest(exchange, request, tradingMode, supportedApiTypes); + if (baseError != null) + return baseError; + if (request.Limit > MaxLimit) return ArgumentError.Invalid(nameof(GetRecentTradesRequest.Limit), $"Only the most recent {MaxLimit} trades are available"); diff --git a/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs b/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs new file mode 100644 index 0000000..9ed234e --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/MessageRoute.cs @@ -0,0 +1,104 @@ +using CryptoExchange.Net.Objects; +using System; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + /// + /// Message route + /// + public abstract class MessageRoute + { + /// + /// Type identifier + /// + public string TypeIdentifier { get; set; } + /// + /// Optional topic filter + /// + public string? TopicFilter { get; set; } + + /// + /// Whether responses to this route might be read by multiple listeners + /// + public bool MultipleReaders { get; set; } = false; + + /// + /// Deserialization type + /// + public abstract Type DeserializationType { get; } + + /// + /// ctor + /// + public MessageRoute(string typeIdentifier, string? topicFilter) + { + TypeIdentifier = typeIdentifier; + TopicFilter = topicFilter; + } + + /// + /// Message handler + /// + public abstract CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data); + } + + /// + /// Message route + /// + public class MessageRoute : MessageRoute + { + private Func _handler; + + /// + public override Type DeserializationType { get; } = typeof(TMessage); + + /// + /// ctor + /// + internal MessageRoute(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) + : base(typeIdentifier, topicFilter) + { + _handler = handler; + MultipleReaders = multipleReaders; + } + + /// + /// Create route without topic filter + /// + public static MessageRoute CreateWithoutTopicFilter(string typeIdentifier, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, null, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + /// Create route with optional topic filter + /// + public static MessageRoute CreateWithOptionalTopicFilter(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, topicFilter, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + /// Create route with topic filter + /// + public static MessageRoute CreateWithTopicFilter(string typeIdentifier, string topicFilter, Func handler, bool multipleReaders = false) + { + return new MessageRoute(typeIdentifier, topicFilter, handler) + { + MultipleReaders = multipleReaders + }; + } + + /// + public override CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data) + { + return _handler(connection, receiveTime, originalData, (TMessage)data); + } + } +} diff --git a/CryptoExchange.Net/Sockets/MessageRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs similarity index 67% rename from CryptoExchange.Net/Sockets/MessageRouter.cs rename to CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs index 4c29763..4a79db6 100644 --- a/CryptoExchange.Net/Sockets/MessageRouter.cs +++ b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs @@ -1,16 +1,17 @@ using CryptoExchange.Net.Objects; -using CryptoExchange.Net.Sockets.Default; using System; using System.Collections.Generic; using System.Linq; -namespace CryptoExchange.Net.Sockets +namespace CryptoExchange.Net.Sockets.Default.Routing { /// /// Message router /// public class MessageRouter { + private ProcessorRouter? _routingTable; + /// /// The routes registered for this router /// @@ -24,12 +25,40 @@ namespace CryptoExchange.Net.Sockets Routes = routes; } + /// + /// Build the route mapping + /// + public void BuildQueryRouter() + { + _routingTable = new QueryRouter(Routes); + } + + /// + /// Build the route mapping + /// + public void BuildSubscriptionRouter() + { + _routingTable = new SubscriptionRouter(Routes); + } + + /// + /// Handle message + /// + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + var routeCollection = (_routingTable ?? throw new NullReferenceException("Routing table not build before handling")).GetRoutes(typeIdentifier); + if (routeCollection == null) + throw new InvalidOperationException($"No routes for {typeIdentifier} message type"); + + return routeCollection.Handle(topicFilter, connection, receiveTime, originalData, data, out result); + } + /// /// Create message router without specific message handler /// public static MessageRouter CreateWithoutHandler(string typeIdentifier, bool multipleReaders = false) { - return new MessageRouter(new MessageRoute(typeIdentifier, (string?)null, (con, receiveTime, originalData, msg) => new CallResult(default, null, null), multipleReaders)); + return new MessageRouter(new MessageRoute(typeIdentifier, null, (con, receiveTime, originalData, msg) => new CallResult(default, null, null), multipleReaders)); } /// @@ -165,104 +194,4 @@ namespace CryptoExchange.Net.Sockets /// public bool ContainsCheck(MessageRoute route) => Routes.Any(x => x.TypeIdentifier == route.TypeIdentifier && x.TopicFilter == route.TopicFilter); } - - /// - /// Message route - /// - public abstract class MessageRoute - { - /// - /// Type identifier - /// - public string TypeIdentifier { get; set; } - /// - /// Optional topic filter - /// - public string? TopicFilter { get; set; } - - /// - /// Whether responses to this route might be read by multiple listeners - /// - public bool MultipleReaders { get; set; } = false; - - /// - /// Deserialization type - /// - public abstract Type DeserializationType { get; } - - /// - /// ctor - /// - public MessageRoute(string typeIdentifier, string? topicFilter) - { - TypeIdentifier = typeIdentifier; - TopicFilter = topicFilter; - } - - /// - /// Message handler - /// - public abstract CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data); - } - - /// - /// Message route - /// - public class MessageRoute : MessageRoute - { - private Func _handler; - - /// - public override Type DeserializationType { get; } = typeof(TMessage); - - /// - /// ctor - /// - internal MessageRoute(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) - : base(typeIdentifier, topicFilter) - { - _handler = handler; - MultipleReaders = multipleReaders; - } - - /// - /// Create route without topic filter - /// - public static MessageRoute CreateWithoutTopicFilter(string typeIdentifier, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, null, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - /// Create route with optional topic filter - /// - public static MessageRoute CreateWithOptionalTopicFilter(string typeIdentifier, string? topicFilter, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, topicFilter, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - /// Create route with topic filter - /// - public static MessageRoute CreateWithTopicFilter(string typeIdentifier, string topicFilter, Func handler, bool multipleReaders = false) - { - return new MessageRoute(typeIdentifier, topicFilter, handler) - { - MultipleReaders = multipleReaders - }; - } - - /// - public override CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data) - { - return _handler(connection, receiveTime, originalData, (TMessage)data); - } - } - } diff --git a/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs new file mode 100644 index 0000000..6e7c37c --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/ProcessorRouter.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal abstract class ProcessorRouter + { + public abstract RouteCollection? GetRoutes(string identifier); + } + + internal abstract class ProcessorRouter : ProcessorRouter + where T : RouteCollection + { +#if NET8_0_OR_GREATER + private FrozenDictionary _routeMap; +#else + private Dictionary _routeMap; +#endif + + public ProcessorRouter(IEnumerable routes) + { + var map = BuildFromRoutes(routes); +#if NET8_0_OR_GREATER + _routeMap = map.ToFrozenDictionary(); +#else + _routeMap = map; +#endif + } + + public abstract Dictionary BuildFromRoutes(IEnumerable routes); + + public override RouteCollection? GetRoutes(string identifier) => _routeMap.TryGetValue(identifier, out var routes) ? routes : null; + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs new file mode 100644 index 0000000..82f12d3 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/QueryRouter.cs @@ -0,0 +1,90 @@ +using CryptoExchange.Net.Objects; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal class QueryRouter : ProcessorRouter + { + public QueryRouter(IEnumerable routes) : base(routes) + { + } + + public override Dictionary BuildFromRoutes(IEnumerable routes) + { + var newMap = new Dictionary(); + foreach (var route in routes) + { + if (!newMap.TryGetValue(route.TypeIdentifier, out var typeMap)) + { + typeMap = new QueryRouteCollection(route.DeserializationType); + newMap.Add(route.TypeIdentifier, typeMap); + } + + typeMap.AddRoute(route.TopicFilter, route); + } + + foreach (var subEntry in newMap.Values) + subEntry.Build(); + + return newMap; + } + } + + internal class QueryRouteCollection : RouteCollection + { + public bool MultipleReaders { get; private set; } + + public QueryRouteCollection(Type routeType) : base(routeType) + { + } + + public override void AddRoute(string? topicFilter, MessageRoute route) + { + base.AddRoute(topicFilter, route); + + if (route.MultipleReaders) + MultipleReaders = true; + } + + public override bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + result = null; + + // Routes without topic filter handle both when the message topic is empty and when it is not, so we always call them + var handled = false; + foreach (var route in _routesWithoutTopicFilter) + { + var thisResult = route.Handle(connection, receiveTime, originalData, data); + if (thisResult != null) + result ??= thisResult; + + handled = true; + } + + // Forward to routes with matching topic filter, if any + if (topicFilter == null) + return handled; + + var matchingTopicRoutes = GetRoutesWithMatchingTopicFilter(topicFilter); + if (matchingTopicRoutes == null) + return handled; + + foreach (var route in matchingTopicRoutes) + { + var thisResult = route.Handle(connection, receiveTime, originalData, data); + handled = true; + + if (thisResult != null) + { + result ??= thisResult; + + if (!MultipleReaders) + break; + } + } + + return handled; + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs new file mode 100644 index 0000000..48df197 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs @@ -0,0 +1,65 @@ +using CryptoExchange.Net.Objects; +using System; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal abstract class RouteCollection + { + protected List _routesWithoutTopicFilter; + protected Dictionary> _routesWithTopicFilter; +#if NET8_0_OR_GREATER + protected FrozenDictionary>? _routesWithTopicFilterFrozen; +#endif + + public Type DeserializationType { get; } + + public RouteCollection(Type routeType) + { + _routesWithoutTopicFilter = new List(); + _routesWithTopicFilter = new Dictionary>(); + + DeserializationType = routeType; + } + + public virtual void AddRoute(string? topicFilter, MessageRoute route) + { + if (string.IsNullOrEmpty(topicFilter)) + { + _routesWithoutTopicFilter.Add(route); + } + else + { + if (!_routesWithTopicFilter.TryGetValue(topicFilter!, out var list)) + { + list = new List(); + _routesWithTopicFilter.Add(topicFilter!, list); + } + + list.Add(route); + } + } + + public void Build() + { +#if NET8_0_OR_GREATER + _routesWithTopicFilterFrozen = _routesWithTopicFilter.ToFrozenDictionary(); +#endif + } + + protected List? GetRoutesWithMatchingTopicFilter(string topicFilter) + { +#if NET8_0_OR_GREATER + _routesWithTopicFilterFrozen!.TryGetValue(topicFilter, out var matchingTopicRoutes); +#else + _routesWithTopicFilter.TryGetValue(topicFilter, out var matchingTopicRoutes); +#endif + return matchingTopicRoutes; + } + + public abstract bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result); + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs b/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs new file mode 100644 index 0000000..05548c2 --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/RoutingTable.cs @@ -0,0 +1,111 @@ +using CryptoExchange.Net.Sockets.Interfaces; +using System; +using System.Collections.Generic; +using System.Text; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + /// + /// Routing table + /// + public class RoutingTable + { +#if NET8_0_OR_GREATER + private FrozenDictionary _typeRoutingCollections = new Dictionary().ToFrozenDictionary(); +#else + private Dictionary _typeRoutingCollections = new(); +#endif + + /// + /// Update the routing table + /// + /// + public void Update(IEnumerable processors) + { + var newTypeMap = new Dictionary(); + foreach (var entry in processors) + { + foreach (var route in entry.MessageRouter.Routes) + { + if (!newTypeMap.ContainsKey(route.TypeIdentifier)) + newTypeMap.Add(route.TypeIdentifier, new TypeRoutingCollection(route.DeserializationType)); + + if (!newTypeMap[route.TypeIdentifier].Handlers.Contains(entry)) + newTypeMap[route.TypeIdentifier].Handlers.Add(entry); + } + } + +#if NET8_0_OR_GREATER + _typeRoutingCollections = newTypeMap.ToFrozenDictionary(); +#else + _typeRoutingCollections = newTypeMap; +#endif + } + + /// + /// Get route table entry for a type identifier + /// + public TypeRoutingCollection? GetRouteTableEntry(string typeIdentifier) + { + return _typeRoutingCollections.TryGetValue(typeIdentifier, out var entry) ? entry : null; + } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + foreach (var entry in _typeRoutingCollections) + { + sb.AppendLine($"{entry.Key}, {entry.Value.DeserializationType.Name}"); + foreach(var item in entry.Value.Handlers) + { + sb.AppendLine($" - Processor {item.GetType().Name}"); + foreach(var route in item.MessageRouter.Routes) + { + if (route.TypeIdentifier == entry.Key) + { + if (route.TopicFilter == null) + sb.AppendLine($" - Route without topic filter"); + else + sb.AppendLine($" - Route with topic filter {route.TopicFilter}"); + } + } + } + } + + return sb.ToString(); + } + } + + /// + /// Routing table entry + /// + public record TypeRoutingCollection + { + /// + /// Whether the deserialization type is string + /// + public bool IsStringOutput { get; set; } + /// + /// The deserialization type + /// + public Type DeserializationType { get; set; } + /// + /// Message processors + /// + public List Handlers { get; set; } + + /// + /// ctor + /// + public TypeRoutingCollection(Type deserializationType) + { + IsStringOutput = deserializationType == typeof(string); + DeserializationType = deserializationType; + Handlers = new List(); + } + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs new file mode 100644 index 0000000..613315c --- /dev/null +++ b/CryptoExchange.Net/Sockets/Default/Routing/SubscriptionRouter.cs @@ -0,0 +1,69 @@ +using CryptoExchange.Net.Objects; +using System; +using System.Collections.Generic; + +namespace CryptoExchange.Net.Sockets.Default.Routing +{ + internal class SubscriptionRouter : ProcessorRouter + { + public SubscriptionRouter(IEnumerable routes) : base(routes) + { + } + + public override Dictionary BuildFromRoutes(IEnumerable routes) + { + var newMap = new Dictionary(); + foreach (var route in routes) + { + if (!newMap.TryGetValue(route.TypeIdentifier, out var typeMap)) + { + typeMap = new SubscriptionRouteCollection(route.DeserializationType); + newMap.Add(route.TypeIdentifier, typeMap); + } + + typeMap.AddRoute(route.TopicFilter, route); + } + + foreach (var subEntry in newMap.Values) + subEntry.Build(); + + return newMap; + } + } + + internal class SubscriptionRouteCollection : RouteCollection + { + public SubscriptionRouteCollection(Type routeType) : base(routeType) + { + } + + public override bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) + { + result = CallResult.SuccessResult; + + // Routes without topic filter handle both when the message topic is empty and when it is not, so we always call them + var handled = false; + foreach (var route in _routesWithoutTopicFilter) + { + route.Handle(connection, receiveTime, originalData, data); + handled = true; + } + + // Forward to routes with matching topic filter, if any + if (topicFilter == null) + return handled; + + var matchingTopicRoutes = GetRoutesWithMatchingTopicFilter(topicFilter); + if (matchingTopicRoutes == null) + return handled; + + foreach (var route in matchingTopicRoutes) + { + route.Handle(connection, receiveTime, originalData, data); + handled = true; + } + + return handled; + } + } +} \ No newline at end of file diff --git a/CryptoExchange.Net/Sockets/Default/SocketConnection.cs b/CryptoExchange.Net/Sockets/Default/SocketConnection.cs index 1abe24a..f9865cd 100644 --- a/CryptoExchange.Net/Sockets/Default/SocketConnection.cs +++ b/CryptoExchange.Net/Sockets/Default/SocketConnection.cs @@ -5,13 +5,12 @@ using CryptoExchange.Net.Logging.Extensions; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects.Sockets; using CryptoExchange.Net.Sockets.Default.Interfaces; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using Microsoft.Extensions.Logging; using System; -using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics; using System.Linq; using System.Net.WebSockets; using System.Text; @@ -262,6 +261,9 @@ namespace CryptoExchange.Net.Sockets.Default #else private readonly object _listenersLock = new object(); #endif + + private RoutingTable _routingTable = new RoutingTable(); + private ReadOnlyCollection _listeners; private readonly ILogger _logger; private SocketStatus _status; @@ -489,6 +491,14 @@ namespace CryptoExchange.Net.Sockets.Default /// protected internal virtual void HandleStreamMessage2(WebSocketMessageType type, ReadOnlySpan data) { + // Forward message rules: + // | Message Topic | Route Topic Filter | Topics Match | Forward | Description + // | N | N | - | Y | No topic filter applied + // | N | Y | - | N | Route only listens to specific topic + // | Y | N | - | Y | Route listens to all message regardless of topic + // | Y | Y | Y | Y | Route listens to specific message topic + // | Y | Y | N | N | Route listens to different topic + var receiveTime = DateTime.UtcNow; // 1. Decrypt/Preprocess if necessary @@ -521,38 +531,22 @@ namespace CryptoExchange.Net.Sockets.Default return; } - Type? deserializationType = null; - foreach (var subscription in _listeners) - { - foreach (var route in subscription.MessageRouter.Routes) - { - if (!route.TypeIdentifier.Equals(typeIdentifier, StringComparison.Ordinal)) - continue; - - deserializationType = route.DeserializationType; - break; - } - - if (deserializationType != null) - break; - } - - if (deserializationType == null) + var routingEntry = _routingTable.GetRouteTableEntry(typeIdentifier); + if (routingEntry == null) { if (!ApiClient.HandleUnhandledMessage(this, typeIdentifier, data)) { // No handler found for identifier either, can't process _logger.LogWarning("Failed to determine message type for identifier {Identifier}. Data: {Message}", typeIdentifier, Encoding.UTF8.GetString(data.ToArray())); } - + return; } - object result; try { - if (deserializationType == typeof(string)) + if (routingEntry.IsStringOutput) { #if NETSTANDARD2_0 result = Encoding.UTF8.GetString(data.ToArray()); @@ -562,7 +556,7 @@ namespace CryptoExchange.Net.Sockets.Default } else { - result = messageConverter.Deserialize(data, deserializationType); + result = messageConverter.Deserialize(data, routingEntry.DeserializationType); } } catch(Exception ex) @@ -579,60 +573,12 @@ namespace CryptoExchange.Net.Sockets.Default } var topicFilter = messageConverter.GetTopicFilter(result); - - bool processed = false; - foreach (var processor in _listeners) + var processed = false; + foreach (var handler in routingEntry.Handlers) { - bool isQuery = false; - Query? query = null; - if (processor is Query cquery) - { - isQuery = true; - query = cquery; - } - - var complete = false; - - foreach (var route in processor.MessageRouter.Routes) - { - if (route.TypeIdentifier != typeIdentifier) - continue; - - // Forward message rules: - // | Message Topic | Route Topic Filter | Topics Match | Forward | Description - // | N | N | - | Y | No topic filter applied - // | N | Y | - | N | Route only listens to specific topic - // | Y | N | - | Y | Route listens to all message regardless of topic - // | Y | Y | Y | Y | Route listens to specific message topic - // | Y | Y | N | N | Route listens to different topic - if (topicFilter == null) - { - if (route.TopicFilter != null) - // No topic on message, but route is filtering on topic - continue; - } - else - { - if (route.TopicFilter != null && !route.TopicFilter.Equals(topicFilter, StringComparison.Ordinal)) - // Message has a topic, and the route has a filter for another topic - continue; - } - + var thisHandled = handler.Handle(typeIdentifier, topicFilter, this, receiveTime, originalData, result); + if (thisHandled) processed = true; - - if (isQuery && query!.Completed) - continue; - - processor.Handle(this, receiveTime, originalData, result, route); - if (isQuery && !route.MultipleReaders) - { - complete = true; - break; - } - } - - if (complete) - break; } if (!processed) @@ -1193,13 +1139,26 @@ namespace CryptoExchange.Net.Sockets.Default }); } + private void UpdateRoutingTable() + { + _routingTable.Update(_listeners); + } + private void AddMessageProcessor(IMessageProcessor processor) { lock (_listenersLock) { var updatedList = new List(_listeners); updatedList.Add(processor); + processor.OnMessageRouterUpdated += UpdateRoutingTable; _listeners = updatedList.AsReadOnly(); + if (processor.MessageRouter.Routes.Length > 0) + { + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processor added, new routing table:\r\n" + _routingTable.ToString()); +#endif + } } } @@ -1208,8 +1167,15 @@ namespace CryptoExchange.Net.Sockets.Default lock (_listenersLock) { var updatedList = new List(_listeners); - updatedList.Remove(processor); + processor.OnMessageRouterUpdated -= UpdateRoutingTable; + if (!updatedList.Remove(processor)) + return; // If nothing removed nothing has changed + _listeners = updatedList.AsReadOnly(); + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processor removed, new routing table:\r\n" + _routingTable.ToString()); +#endif } } @@ -1218,12 +1184,24 @@ namespace CryptoExchange.Net.Sockets.Default lock (_listenersLock) { var updatedList = new List(_listeners); + var anyRemoved = false; foreach (var processor in processors) - updatedList.Remove(processor); + { + processor.OnMessageRouterUpdated -= UpdateRoutingTable; + if (updatedList.Remove(processor)) + anyRemoved = true; + } + + if (!anyRemoved) + return; // If nothing removed nothing has changed + _listeners = updatedList.AsReadOnly(); + UpdateRoutingTable(); +#if DEBUG + _logger.LogTrace("Processors removed, new routing table:\r\n" + _routingTable.ToString()); +#endif } } - } } diff --git a/CryptoExchange.Net/Sockets/Default/Subscription.cs b/CryptoExchange.Net/Sockets/Default/Subscription.cs index fd460e6..9ddc18d 100644 --- a/CryptoExchange.Net/Sockets/Default/Subscription.cs +++ b/CryptoExchange.Net/Sockets/Default/Subscription.cs @@ -1,5 +1,6 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using Microsoft.Extensions.Logging; using System; @@ -70,10 +71,21 @@ namespace CryptoExchange.Net.Sockets.Default /// public bool Authenticated { get; } + + private MessageRouter _router; /// /// Router for this subscription /// - public MessageRouter MessageRouter { get; set; } + public MessageRouter MessageRouter + { + get => _router; + set + { + _router = value; + _router.BuildSubscriptionRouter(); + OnMessageRouterUpdated?.Invoke(); + } + } /// /// Cancellation token registration @@ -109,6 +121,9 @@ namespace CryptoExchange.Net.Sockets.Default /// public int IndividualSubscriptionCount { get; set; } = 1; + /// + public event Action? OnMessageRouterUpdated; + /// /// ctor /// @@ -170,10 +185,11 @@ namespace CryptoExchange.Net.Sockets.Default /// /// Handle an update message /// - public CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object data, MessageRoute route) + public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data) { ConnectionInvocations++; TotalInvocations++; + if (SubscriptionQuery != null && !SubscriptionQuery.Completed && SubscriptionQuery.TimeoutBehavior == TimeoutBehavior.Succeed) { // The subscription query is one where it is successful if there is no error returned @@ -182,7 +198,7 @@ namespace CryptoExchange.Net.Sockets.Default SubscriptionQuery.Timeout(); } - return route.Handle(connection, receiveTime, originalData, data); + return MessageRouter.Handle(typeIdentifier, topicFilter, connection, receiveTime, originalData, data, out _); } /// diff --git a/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs b/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs index 24e5d5b..7c7561f 100644 --- a/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs +++ b/CryptoExchange.Net/Sockets/Interfaces/IMessageProcessor.cs @@ -1,6 +1,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; using System; namespace CryptoExchange.Net.Sockets.Interfaces @@ -19,8 +20,12 @@ namespace CryptoExchange.Net.Sockets.Interfaces /// public MessageRouter MessageRouter { get; } /// + /// Event when the message router for this processor has been changed + /// + public event Action? OnMessageRouterUpdated; + /// /// Handle a message /// - CallResult? Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object result, MessageRoute route); + bool Handle(string typeIdentifier, string? topicFilter, SocketConnection socketConnection, DateTime receiveTime, string? originalData, object result); } } diff --git a/CryptoExchange.Net/Sockets/Query.cs b/CryptoExchange.Net/Sockets/Query.cs index 8769814..60fb635 100644 --- a/CryptoExchange.Net/Sockets/Query.cs +++ b/CryptoExchange.Net/Sockets/Query.cs @@ -1,6 +1,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; using CryptoExchange.Net.Sockets.Interfaces; using System; using System.Threading; @@ -59,10 +60,20 @@ namespace CryptoExchange.Net.Sockets /// public object? Response { get; set; } + private MessageRouter _router; /// /// Router for this query /// - public MessageRouter MessageRouter { get; set; } + public MessageRouter MessageRouter + { + get => _router; + set + { + _router = value; + _router.BuildQueryRouter(); + OnMessageRouterUpdated?.Invoke(); + } + } /// /// The query request object @@ -99,6 +110,9 @@ namespace CryptoExchange.Net.Sockets /// public Action? OnComplete { get; set; } + /// + public event Action? OnMessageRouterUpdated; + /// /// ctor /// @@ -155,7 +169,7 @@ namespace CryptoExchange.Net.Sockets /// /// Handle a response message /// - public abstract CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageRoute route); + public abstract bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object message); } @@ -185,17 +199,23 @@ namespace CryptoExchange.Net.Sockets } /// - public override CallResult Handle(SocketConnection connection, DateTime receiveTime, string? originalData, object message, MessageRoute route) + public override bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object message) { + if (Completed) + return false; + CurrentResponses++; if (CurrentResponses == RequiredResponses) Response = message; + var handled = false; if (Result?.Success != false) { // If an error result is already set don't override that - Result = route.Handle(connection, receiveTime, originalData, message); - if (Result == null) + MessageRouter.Handle(typeIdentifier, topicFilter, connection, receiveTime, originalData, message, out var result); + Result = result; + handled = Result != null; + if (!handled) // Null from Handle means it wasn't actually for this query CurrentResponses -= 1; } @@ -207,7 +227,7 @@ namespace CryptoExchange.Net.Sockets OnComplete?.Invoke(); } - return Result ?? CallResult.SuccessResult; + return handled; } /// diff --git a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs index 9dc30e8..60b4cca 100644 --- a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs +++ b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs @@ -15,6 +15,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } public override void WriteLine(string? message) @@ -27,6 +30,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } } } diff --git a/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs b/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs index 498222a..571f8a6 100644 --- a/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs +++ b/CryptoExchange.Net/Testing/SharedRestRequestValidator.cs @@ -48,6 +48,7 @@ namespace CryptoExchange.Net.Testing /// Method invocation /// Method name for looking up json test values /// Request options + /// Callback to validate the response model /// /// public Task ValidateAsync( @@ -65,6 +66,7 @@ namespace CryptoExchange.Net.Testing /// Method invocation /// Method name for looking up json test values /// Request options + /// Callback to validate the response model /// /// public async Task ValidateAsync(