diff --git a/AyCode.Core/Compression/BrotliHelper.cs b/AyCode.Core/Compression/BrotliHelper.cs new file mode 100644 index 0000000..3739023 --- /dev/null +++ b/AyCode.Core/Compression/BrotliHelper.cs @@ -0,0 +1,114 @@ +using System.IO.Compression; +using System.Text; + +namespace AyCode.Core.Compression; + +/// +/// Brotli compression/decompression helper for SignalR message transport. +/// Used when JSON serializer is configured to reduce payload size. +/// +public static class BrotliHelper +{ + /// + /// Compresses a string using Brotli compression. + /// + /// The text to compress. + /// Compression level (default: Optimal). + /// Compressed byte array. + public static byte[] Compress(string text, CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + if (string.IsNullOrEmpty(text)) + return []; + + var bytes = Encoding.UTF8.GetBytes(text); + return Compress(bytes, compressionLevel); + } + + /// + /// Compresses a byte array using Brotli compression. + /// + /// The data to compress. + /// Compression level (default: Optimal). + /// Compressed byte array. + public static byte[] Compress(byte[] data, CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + if (data == null || data.Length == 0) + return []; + + using var outputStream = new MemoryStream(); + using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true)) + { + brotliStream.Write(data, 0, data.Length); + } + + return outputStream.ToArray(); + } + + /// + /// Decompresses Brotli-compressed data to a string. + /// + /// The compressed data. + /// Decompressed string. + public static string DecompressToString(byte[] compressedData) + { + if (compressedData == null || compressedData.Length == 0) + return string.Empty; + + var decompressedBytes = Decompress(compressedData); + return Encoding.UTF8.GetString(decompressedBytes); + } + + /// + /// Decompresses Brotli-compressed data to a byte array. + /// + /// The compressed data. + /// Decompressed byte array. + public static byte[] Decompress(byte[] compressedData) + { + if (compressedData == null || compressedData.Length == 0) + return []; + + using var inputStream = new MemoryStream(compressedData); + using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress); + using var outputStream = new MemoryStream(); + + brotliStream.CopyTo(outputStream); + return outputStream.ToArray(); + } + + /// + /// Checks if the data appears to be Brotli compressed. + /// Brotli doesn't have a magic number, so we use a heuristic approach. + /// + /// The data to check. + /// True if the data might be Brotli compressed. + public static bool IsBrotliCompressed(byte[] data) + { + if (data == null || data.Length < 4) + return false; + + // Brotli doesn't have a magic header like gzip (0x1F 0x8B) + // We check if it's NOT valid UTF-8 JSON (starts with { or [) + // and try to decompress + var firstByte = data[0]; + + // If it starts with '{' (0x7B) or '[' (0x5B), it's likely uncompressed JSON + if (firstByte == 0x7B || firstByte == 0x5B) + return false; + + // Try to decompress - if it fails, it's not Brotli + try + { + using var inputStream = new MemoryStream(data); + using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress); + + // Try to read first byte + var buffer = new byte[1]; + return brotliStream.Read(buffer, 0, 1) >= 0; + } + catch + { + return false; + } + } +} diff --git a/AyCode.Core/Extensions/AcJsonDeserializer.cs b/AyCode.Core/Extensions/AcJsonDeserializer.cs index 5d5b591..e16d203 100644 --- a/AyCode.Core/Extensions/AcJsonDeserializer.cs +++ b/AyCode.Core/Extensions/AcJsonDeserializer.cs @@ -1323,12 +1323,24 @@ public static class AcJsonDeserializer case TypeCode.Decimal: result = decimal.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.Single: result = float.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.String: + // If already unwrapped (no quotes), return as-is; otherwise parse JSON + if (json.Length == 0 || json[0] != '"') + { + result = json; + return true; + } using (var doc = JsonDocument.Parse(json)) { result = doc.RootElement.GetString(); return true; } case TypeCode.DateTime: + // If already unwrapped (no quotes), parse directly; otherwise use JSON parser + if (json.Length == 0 || json[0] != '"') + { + result = DateTime.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); + return true; + } using (var doc = JsonDocument.Parse(json)) { result = doc.RootElement.GetDateTime(); @@ -1341,6 +1353,11 @@ public static class AcJsonDeserializer case TypeCode.UInt64: result = ulong.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.SByte: result = sbyte.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.Char: + if (json.Length == 0 || json[0] != '"') + { + result = json.Length > 0 ? json[0] : '\0'; + return true; + } using (var doc = JsonDocument.Parse(json)) { var s = doc.RootElement.GetString(); @@ -1351,6 +1368,12 @@ public static class AcJsonDeserializer if (ReferenceEquals(type, GuidType)) { + // If already unwrapped (no quotes), parse directly + if (json.Length == 0 || json[0] != '"') + { + result = Guid.Parse(json); + return true; + } using var doc = JsonDocument.Parse(json); result = doc.RootElement.GetGuid(); return true; @@ -1358,6 +1381,12 @@ public static class AcJsonDeserializer if (ReferenceEquals(type, DateTimeOffsetType)) { + // If already unwrapped (no quotes), parse directly + if (json.Length == 0 || json[0] != '"') + { + result = DateTimeOffset.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); + return true; + } using var doc = JsonDocument.Parse(json); result = doc.RootElement.GetDateTimeOffset(); return true; @@ -1365,6 +1394,12 @@ public static class AcJsonDeserializer if (ReferenceEquals(type, TimeSpanType)) { + // If already unwrapped (no quotes), parse directly + if (json.Length == 0 || json[0] != '"') + { + result = TimeSpan.Parse(json, CultureInfo.InvariantCulture); + return true; + } using var doc = JsonDocument.Parse(json); result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture); return true; diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs index cae3976..3be6eee 100644 --- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs +++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.Concurrent; +using System.Buffers; +using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Serialization; @@ -337,6 +338,8 @@ public static class SerializeObjectExtensions Formatting = Formatting.None, }; + #region JSON Serialization + /// /// Serialize object to JSON string with default options. /// @@ -414,29 +417,9 @@ public static class SerializeObjectExtensions AcJsonDeserializer.Populate(json, target, options); } - /// - /// Clone object via JSON serialization with default options. - /// - public static TDestination? CloneTo(this object? src) where TDestination : class - => src?.ToJson().JsonTo(); + #endregion - /// - /// Clone object via JSON serialization with specified options. - /// - public static TDestination? CloneTo(this object? src, AcJsonSerializerOptions options) where TDestination : class - => src?.ToJson(options).JsonTo(options); - - /// - /// Copy object properties to target via JSON with default options. - /// - public static void CopyTo(this object? src, object target) - => src?.ToJson().JsonTo(target); - - /// - /// Copy object properties to target via JSON with specified options. - /// - public static void CopyTo(this object? src, object target, AcJsonSerializerOptions options) - => src?.ToJson(options).JsonTo(target, options); + #region MessagePack public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options); @@ -444,6 +427,9 @@ public static class SerializeObjectExtensions public static T MessagePackTo(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize(message, options); + #endregion + + #region Any (JSON or Binary based on options) public static object ToAny(this T source, AcSerializerOptions options) { @@ -488,37 +474,17 @@ public static class SerializeObjectExtensions public static void AnyToMerge(this object data, T target, AcSerializerOptions options) where T : class { if (options.SerializerType == AcSerializerType.Json) - ((string)data).JsonTo(target, (AcJsonSerializerOptions)options); // JSON always merges + ((string)data).JsonTo(target, (AcJsonSerializerOptions)options); else ((byte[])data).BinaryToMerge(target); } - /// - /// Clone object via serialization based on options. - /// - public static T? CloneToAny(this T source, AcSerializerOptions options) where T : class - { - if (options.SerializerType == AcSerializerType.Json) - return source.CloneTo((AcJsonSerializerOptions)options); - return source.BinaryCloneTo(); - } + #endregion - /// - /// Copy object properties to target via serialization based on options. - /// - public static void CopyToAny(this T source, T target, AcSerializerOptions options) where T : class - { - if (options.SerializerType == AcSerializerType.Json) - source.CopyTo(target, (AcJsonSerializerOptions)options); - else - source.BinaryCopyTo(target); - } - - #region Binary Serialization Extension Methods + #region Binary Serialization /// /// Serialize object to binary byte array with default options. - /// Significantly faster than JSON, especially for large data in WASM. /// public static byte[] ToBinary(this T source) => AcBinarySerializer.Serialize(source); @@ -529,29 +495,83 @@ public static class SerializeObjectExtensions => AcBinarySerializer.Serialize(source, options); /// - /// Deserialize binary data to object with default options. + /// Serialize object directly to an IBufferWriter for zero-copy scenarios. + /// + public static void ToBinary(this T source, IBufferWriter writer) + => AcBinarySerializer.Serialize(source, writer, AcBinarySerializerOptions.Default); + + /// + /// Serialize object directly to an IBufferWriter with specified options. + /// + public static void ToBinary(this T source, IBufferWriter writer, AcBinarySerializerOptions options) + => AcBinarySerializer.Serialize(source, writer, options); + + /// + /// Get the serialized binary size without allocating the final array. + /// + public static int GetBinarySize(this T source) + => AcBinarySerializer.GetSerializedSize(source, AcBinarySerializerOptions.Default); + + /// + /// Get the serialized binary size with specified options. + /// + public static int GetBinarySize(this T source, AcBinarySerializerOptions options) + => AcBinarySerializer.GetSerializedSize(source, options); + + /// + /// Deserialize binary data to object. /// public static T? BinaryTo(this byte[] data) => AcBinaryDeserializer.Deserialize(data); /// - /// Deserialize binary data to object. + /// Deserialize binary data from ReadOnlySpan. /// public static T? BinaryTo(this ReadOnlySpan data) => AcBinaryDeserializer.Deserialize(data); + /// + /// Deserialize binary data from ReadOnlyMemory. + /// + public static T? BinaryTo(this ReadOnlyMemory data) + => AcBinaryDeserializer.Deserialize(data.Span); + /// /// Deserialize binary data to specified type. /// public static object? BinaryTo(this byte[] data, Type targetType) => AcBinaryDeserializer.Deserialize(data.AsSpan(), targetType); + /// + /// Deserialize binary data from ReadOnlySpan to specified type. + /// + public static object? BinaryTo(this ReadOnlySpan data, Type targetType) + => AcBinaryDeserializer.Deserialize(data, targetType); + + /// + /// Deserialize binary data from ReadOnlyMemory to specified type. + /// + public static object? BinaryTo(this ReadOnlyMemory data, Type targetType) + => AcBinaryDeserializer.Deserialize(data.Span, targetType); + /// /// Populate existing object from binary data. /// public static void BinaryTo(this byte[] data, T target) where T : class => AcBinaryDeserializer.Populate(data, target); + /// + /// Populate existing object from binary ReadOnlySpan. + /// + public static void BinaryTo(this ReadOnlySpan data, T target) where T : class + => AcBinaryDeserializer.Populate(data, target); + + /// + /// Populate existing object from binary ReadOnlyMemory. + /// + public static void BinaryTo(this ReadOnlyMemory data, T target) where T : class + => AcBinaryDeserializer.Populate(data.Span, target); + /// /// Populate existing object from binary data with merge semantics for IId collections. /// @@ -559,16 +579,46 @@ public static class SerializeObjectExtensions => AcBinaryDeserializer.PopulateMerge(data.AsSpan(), target); /// - /// Clone object via binary serialization (faster than JSON clone). + /// Populate existing object from binary ReadOnlySpan with merge semantics. /// - public static T? BinaryCloneTo(this T source) where T : class - => source?.ToBinary().BinaryTo(); + public static void BinaryToMerge(this ReadOnlySpan data, T target) where T : class + => AcBinaryDeserializer.PopulateMerge(data, target); /// - /// Copy object properties to target via binary serialization. + /// Populate existing object from binary ReadOnlyMemory with merge semantics. /// - public static void BinaryCopyTo(this T source, T target) where T : class - => source?.ToBinary().BinaryTo(target); + public static void BinaryToMerge(this ReadOnlyMemory data, T target) where T : class + => AcBinaryDeserializer.PopulateMerge(data.Span, target); + + #endregion + + #region Clone and Copy (Binary-based, zero intermediate allocation) + + /// + /// Clone object via binary serialization (zero intermediate byte[] allocation). + /// Uses ArrayBufferWriter to serialize directly into a buffer, then deserializes from the span. + /// + public static TDestination? CloneTo(this object? src) where TDestination : class + { + if (src == null) return null; + + var buffer = new ArrayBufferWriter(256); + AcBinarySerializer.Serialize(src, buffer, AcBinarySerializerOptions.Default); + return AcBinaryDeserializer.Deserialize(buffer.WrittenSpan); + } + + /// + /// Copy object properties to target via binary serialization (zero intermediate byte[] allocation). + /// Uses ArrayBufferWriter to serialize directly into a buffer, then populates target from the span. + /// + public static void CopyTo(this object? src, object target) + { + if (src == null) return; + + var buffer = new ArrayBufferWriter(256); + AcBinarySerializer.Serialize(src, buffer, AcBinarySerializerOptions.Default); + AcBinaryDeserializer.Populate(buffer.WrittenSpan, target); + } #endregion } diff --git a/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs b/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs deleted file mode 100644 index 56e8ef2..0000000 --- a/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs +++ /dev/null @@ -1,1301 +0,0 @@ -using AyCode.Core.Enums; -using AyCode.Core.Extensions; -using AyCode.Core.Helpers; -using AyCode.Core.Interfaces; -using AyCode.Core.Loggers; -using AyCode.Services.SignalRs; -using AyCode.Services.Server.SignalRs; -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; - -namespace AyCode.Services.Server.Tests.SignalRs; - -#region Test Models - -public class TestDataItem : IId -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public int Value { get; set; } - - public TestDataItem() { } - public TestDataItem(int id, string name, int value = 0) - { - Id = id; - Name = name; - Value = value; - } - - public override string ToString() => $"TestDataItem[{Id}, {Name}, {Value}]"; -} - -public class TestDataSource : AcSignalRDataSource> -{ - public TestDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags signalRCrudTags, object[]? contextIds = null) - : base(signalRClient, signalRCrudTags, contextIds) - { - } -} - -public class TestObservableDataSource : AcSignalRDataSource> -{ - public TestObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags signalRCrudTags, object[]? contextIds = null) - : base(signalRClient, signalRCrudTags, contextIds) - { - } -} - -#endregion - -#region Mock SignalR Client - -/// -/// Mock SignalR client for testing AcSignalRDataSource without actual network calls. -/// Uses the test constructor to avoid real HubConnection. -/// -public class MockSignalRClient : AcSignalRClientBase -{ - private readonly ConcurrentDictionary> _responseHandlers = new(); - private readonly ConcurrentBag<(int MessageTag, object? Data, DateTime Timestamp)> _sentMessages = new(); - private static int _idCounter; - - public IReadOnlyList<(int MessageTag, object? Data, DateTime Timestamp)> SentMessages - => _sentMessages.OrderBy(x => x.Timestamp).ToList(); - - public int GetSentMessageCount(int messageTag) - => _sentMessages.Count(m => m.MessageTag == messageTag); - - public static int NextId() => Interlocked.Increment(ref _idCounter); - public static void ResetIdCounter() => _idCounter = 0; - - /// - /// Uses test constructor - no real HubConnection created. - /// - public MockSignalRClient() : base(new MockLogger()) - { - } - - /// - /// Setup a static response for a specific message tag - /// - public void SetupResponse(int messageTag, TResponse response) - { - _responseHandlers[messageTag] = _ => response; - } - - /// - /// Setup a dynamic response based on the request - /// - public void SetupResponse(int messageTag, Func responseFactory) - { - _responseHandlers[messageTag] = req => responseFactory((TRequest?)req); - } - - /// - /// Setup a response that returns the posted data (echo) - /// - public void SetupEchoResponse(int messageTag) where T : class - { - _responseHandlers[messageTag] = req => req; - } - - /// - /// Clear all response handlers - /// - public void ClearResponses() - { - _responseHandlers.Clear(); - } - - public override Task GetAllAsync(int messageTag, object[]? contextParams) where TResponseData : default - { - _sentMessages.Add((messageTag, contextParams, DateTime.UtcNow)); - - if (_responseHandlers.TryGetValue(messageTag, out var handler)) - { - return Task.FromResult((TResponseData?)handler(contextParams)); - } - return Task.FromResult(default(TResponseData)); - } - - public override Task GetAllAsync(int messageTag, Func, Task> responseCallback, object[]? contextParams) where TResponseData : default - { - _sentMessages.Add((messageTag, contextParams, DateTime.UtcNow)); - - if (_responseHandlers.TryGetValue(messageTag, out var handler)) - { - var response = (TResponseData?)handler(contextParams); - var responseJson = response?.ToJson(); - ISignalResponseMessage message = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, responseJson); - return responseCallback(message); - } - - ISignalResponseMessage errorMessage = new SignalResponseMessage(messageTag, SignalResponseStatus.Error, (string?)null); - return responseCallback(errorMessage); - } - - public override Task GetByIdAsync(int messageTag, object id) where TResponseData : default - { - _sentMessages.Add((messageTag, id, DateTime.UtcNow)); - - if (_responseHandlers.TryGetValue(messageTag, out var handler)) - { - return Task.FromResult((TResponseData?)handler(id)); - } - return Task.FromResult(default(TResponseData)); - } - - public override Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class - { - _sentMessages.Add((messageTag, postData, DateTime.UtcNow)); - - if (_responseHandlers.TryGetValue(messageTag, out var handler)) - { - return Task.FromResult((TPostData?)handler(postData)); - } - // Default: echo back the posted data - return Task.FromResult(postData); - } - - public override Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) where TPostData : default - { - _sentMessages.Add((messageTag, postData, DateTime.UtcNow)); - - if (_responseHandlers.TryGetValue(messageTag, out var handler)) - { - var response = (TPostData?)handler(postData); - var responseJson = response?.ToJson(); - ISignalResponseMessage message = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, responseJson); - return responseCallback(message); - } - - // Default: echo back the posted data - ISignalResponseMessage successMessage = new SignalResponseMessage(messageTag, SignalResponseStatus.Success, postData?.ToJson()); - return responseCallback(successMessage); - } - - protected override Task MessageReceived(int messageTag, byte[] messageBytes) - { - return Task.CompletedTask; - } -} - -/// -/// Silent logger for testing - does not require appsettings.json -/// -public class MockLogger : AcLoggerBase -{ - private readonly List _logs = new(); - public IReadOnlyList Logs => _logs; - - public MockLogger() : base(AppType.TestUnit, LogLevel.Error, "MockLogger") - { - } - - public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) - => _logs.Add($"DETAIL: {text}"); - - public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) - => _logs.Add($"DEBUG: {text}"); - - public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) - => _logs.Add($"INFO: {text}"); - - public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) - => _logs.Add($"WARN: {text}"); - - public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null) - => _logs.Add($"ERROR: {text}"); -} - -#endregion - -[TestClass] -public class AcSignalRDataSourceTests -{ - private const int GetAllTag = 100; - private const int GetItemTag = 101; - private const int AddTag = 102; - private const int UpdateTag = 103; - private const int RemoveTag = 104; - - private MockSignalRClient _mockClient = null!; - private SignalRCrudTags _crudTags = null!; - private TestDataSource _dataSource = null!; - - [TestInitialize] - public void Setup() - { - MockSignalRClient.ResetIdCounter(); - _mockClient = new MockSignalRClient(); - _crudTags = new SignalRCrudTags(GetAllTag, GetItemTag, AddTag, UpdateTag, RemoveTag); - _dataSource = new TestDataSource(_mockClient, _crudTags); - } - - #region Basic CRUD Tests - - [TestMethod] - public void Add_ValidItem_AddsToCollection() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - - // Act - _dataSource.Add(item); - - // Assert - Assert.AreEqual(1, _dataSource.Count); - Assert.IsTrue(_dataSource.Contains(item)); - } - - [TestMethod] - public void Add_ItemWithDefaultId_ThrowsArgumentNullException() - { - // Arrange - var item = new TestDataItem(0, "Invalid Item"); // 0 is default for int - - // Act & Assert - try - { - _dataSource.Add(item); - Assert.Fail("Expected ArgumentNullException was not thrown"); - } - catch (ArgumentNullException) - { - // Expected - } - } - - [TestMethod] - public void Add_DuplicateItem_ThrowsArgumentException() - { - // Arrange - var id = MockSignalRClient.NextId(); - var item1 = new TestDataItem(id, "Item 1"); - var item2 = new TestDataItem(id, "Item 2"); - _dataSource.Add(item1); - - // Act & Assert - try - { - _dataSource.Add(item2); - Assert.Fail("Expected ArgumentException was not thrown"); - } - catch (ArgumentException) - { - // Expected - } - } - - [TestMethod] - public void Remove_ExistingItem_RemovesFromCollection() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.Remove(item); - - // Assert - Assert.IsTrue(result); - Assert.AreEqual(0, _dataSource.Count); - } - - [TestMethod] - public void Remove_NonExistingItem_ReturnsFalse() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - - // Act - var result = _dataSource.Remove(item); - - // Assert - Assert.IsFalse(result); - } - - [TestMethod] - public void Indexer_ValidIndex_ReturnsItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource[0]; - - // Assert - Assert.AreEqual(item.Id, result.Id); - } - - [TestMethod] - public void Indexer_InvalidIndex_ThrowsArgumentOutOfRangeException() - { - // Act & Assert - try - { - var _ = _dataSource[0]; - Assert.Fail("Expected ArgumentOutOfRangeException was not thrown"); - } - catch (ArgumentOutOfRangeException) - { - // Expected - } - } - - [TestMethod] - public void Insert_ValidIndexAndItem_InsertsAtCorrectPosition() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - var item3 = new TestDataItem(MockSignalRClient.NextId(), "Item 3"); - - _dataSource.Add(item1); - _dataSource.Add(item3); - - // Act - _dataSource.Insert(1, item2); - - // Assert - Assert.AreEqual(3, _dataSource.Count); - Assert.AreEqual(item2.Id, _dataSource[1].Id); - } - - [TestMethod] - public void Clear_WithItems_RemovesAllItems() - { - // Arrange - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 1")); - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 2")); - - // Act - _dataSource.Clear(); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - } - - [TestMethod] - public void TryGetValue_ExistingId_ReturnsTrue() - { - // Arrange - var id = MockSignalRClient.NextId(); - var item = new TestDataItem(id, "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.TryGetValue(id, out var foundItem); - - // Assert - Assert.IsTrue(result); - Assert.IsNotNull(foundItem); - Assert.AreEqual(id, foundItem.Id); - } - - [TestMethod] - public void TryGetValue_NonExistingId_ReturnsFalse() - { - // Act - var result = _dataSource.TryGetValue(999, out var foundItem); - - // Assert - Assert.IsFalse(result); - Assert.IsNull(foundItem); - } - - [TestMethod] - public void RemoveAt_ValidIndex_RemovesItem() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - // Act - _dataSource.RemoveAt(0); - - // Assert - Assert.AreEqual(1, _dataSource.Count); - Assert.AreEqual(item2.Id, _dataSource[0].Id); - } - - #endregion - - #region Tracking Tests - - [TestMethod] - public void Add_CreatesTrackingItem_WithAddState() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - - // Act - _dataSource.Add(item); - - // Assert - var trackingItems = _dataSource.GetTrackingItems(); - Assert.AreEqual(1, trackingItems.Count); - Assert.AreEqual(TrackingState.Add, trackingItems[0].TrackingState); - Assert.AreEqual(item.Id, trackingItems[0].CurrentValue.Id); - } - - [TestMethod] - public void Remove_AfterAdd_RemovesTrackingItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - _dataSource.Remove(item); - - // Assert - Tracking should be empty because Add followed by Remove cancels out - var trackingItems = _dataSource.GetTrackingItems(); - Assert.AreEqual(0, trackingItems.Count); - } - - [TestMethod] - public void TryGetTrackingItem_ExistingItem_ReturnsTrue() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.TryGetTrackingItem(item.Id, out var trackingItem); - - // Assert - Assert.IsTrue(result); - Assert.IsNotNull(trackingItem); - Assert.AreEqual(TrackingState.Add, trackingItem.TrackingState); - } - - [TestMethod] - public void SetTrackingStateToUpdate_AddsUpdateTracking() - { - // Arrange - Load data without tracking - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item", 1); - var innerList = _dataSource.GetReferenceInnerList(); - innerList.Add(item); - - // Act - _dataSource.SetTrackingStateToUpdate(item); - - // Assert - Assert.IsTrue(_dataSource.TryGetTrackingItem(item.Id, out var trackingItem)); - Assert.AreEqual(TrackingState.Update, trackingItem!.TrackingState); - } - - [TestMethod] - public void SetTrackingStateToUpdate_DoesNotChangeAddState() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - _dataSource.SetTrackingStateToUpdate(item); - - // Assert - Should still be Add, not Update - Assert.IsTrue(_dataSource.TryGetTrackingItem(item.Id, out var trackingItem)); - Assert.AreEqual(TrackingState.Add, trackingItem!.TrackingState); - } - - [TestMethod] - public void TryRollbackItem_RollsBackAddedItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.TryRollbackItem(item.Id, out var originalValue); - - // Assert - Assert.IsTrue(result); - Assert.IsNull(originalValue); // Added items have no original value - Assert.AreEqual(0, _dataSource.Count); - } - - [TestMethod] - public void Rollback_RollsBackAllChanges() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - // Act - _dataSource.Rollback(); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); - } - - [TestMethod] - public void Clear_WithClearChangeTrackingFalse_KeepsTrackingItems() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - _dataSource.Clear(clearChangeTracking: false); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - Assert.AreEqual(1, _dataSource.GetTrackingItems().Count); - } - - [TestMethod] - public void Clear_WithClearChangeTrackingTrue_RemovesTrackingItems() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - _dataSource.Clear(clearChangeTracking: true); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); - } - - #endregion - - #region Async Save Tests - - [TestMethod] - public async Task Add_WithAutoSave_CallsSignalRClient() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _mockClient.SetupEchoResponse(AddTag); - - // Act - var result = await _dataSource.Add(item, autoSave: true); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(item.Id, result.Id); - Assert.AreEqual(1, _mockClient.GetSentMessageCount(AddTag)); - } - - [TestMethod] - public async Task Add_WithAutoSaveFalse_DoesNotCallSignalR() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - - // Act - var result = await _dataSource.Add(item, autoSave: false); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(0, _mockClient.GetSentMessageCount(AddTag)); - } - - [TestMethod] - public async Task Remove_WithAutoSave_CallsSignalRClient() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - _mockClient.SetupEchoResponse(RemoveTag); - - // Act - var result = await _dataSource.Remove(item, autoSave: true); - - // Assert - Assert.IsTrue(result); - Assert.AreEqual(1, _mockClient.GetSentMessageCount(RemoveTag)); - } - - [TestMethod] - public async Task Update_WithAutoSave_CallsSignalRClient() - { - // Arrange - var id = MockSignalRClient.NextId(); - var item = new TestDataItem(id, "Test Item", 1); - _dataSource.Add(item); - - var updatedItem = new TestDataItem(id, "Updated Item", 2); - _mockClient.SetupEchoResponse(UpdateTag); - - // Act - var result = await _dataSource.Update(updatedItem, autoSave: true); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(1, _mockClient.GetSentMessageCount(UpdateTag)); - } - - [TestMethod] - public async Task AddOrUpdate_NewItem_AddsItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "New Item"); - _mockClient.SetupEchoResponse(AddTag); - - // Act - var result = await _dataSource.AddOrUpdate(item, autoSave: true); - - // Assert - Assert.AreEqual(1, _dataSource.Count); - Assert.AreEqual(1, _mockClient.GetSentMessageCount(AddTag)); - Assert.AreEqual(0, _mockClient.GetSentMessageCount(UpdateTag)); - } - - [TestMethod] - public async Task AddOrUpdate_ExistingItem_UpdatesItem() - { - // Arrange - var id = MockSignalRClient.NextId(); - var item = new TestDataItem(id, "Original", 1); - _dataSource.Add(item); - - var updatedItem = new TestDataItem(id, "Updated", 2); - _mockClient.SetupEchoResponse(UpdateTag); - - // Act - var result = await _dataSource.AddOrUpdate(updatedItem, autoSave: true); - - // Assert - Assert.AreEqual(1, _dataSource.Count); - Assert.AreEqual(1, _mockClient.GetSentMessageCount(UpdateTag)); - } - - [TestMethod] - public async Task SaveChanges_SavesAllTrackedItems() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - _mockClient.SetupEchoResponse(AddTag); - - // Act - var unsavedItems = await _dataSource.SaveChanges(); - - // Assert - Assert.AreEqual(0, unsavedItems.Count); - Assert.AreEqual(2, _mockClient.GetSentMessageCount(AddTag)); - } - - [TestMethod] - public async Task SaveChangesAsync_SavesAllTrackedItems() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - _mockClient.SetupEchoResponse(AddTag); - - // Act - await _dataSource.SaveChangesAsync(); - - // Assert - Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); - } - - #endregion - - #region Load Tests - - [TestMethod] - public async Task LoadDataSource_LoadsItemsFromSignalR() - { - // Arrange - var items = new List - { - new(MockSignalRClient.NextId(), "Item 1"), - new(MockSignalRClient.NextId(), "Item 2"), - new(MockSignalRClient.NextId(), "Item 3") - }; - _mockClient.SetupResponse(GetAllTag, items); - - // Act - await _dataSource.LoadDataSource(); - - // Assert - Assert.AreEqual(3, _dataSource.Count); - Assert.AreEqual(0, _dataSource.GetTrackingItems().Count); // No tracking for loaded items - } - - [TestMethod] - public async Task LoadItem_LoadsSingleItemFromSignalR() - { - // Arrange - var id = MockSignalRClient.NextId(); - var item = new TestDataItem(id, "Loaded Item"); - _mockClient.SetupResponse(GetItemTag, item); - - // Act - var result = await _dataSource.LoadItem(id); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(id, result.Id); - Assert.AreEqual(1, _dataSource.Count); - } - - [TestMethod] - public async Task LoadItem_ReturnsNullForNonExisting() - { - // Arrange - _mockClient.SetupResponse(GetItemTag, null); - - // Act - var result = await _dataSource.LoadItem(999); - - // Assert - Assert.IsNull(result); - Assert.AreEqual(0, _dataSource.Count); - } - - [TestMethod] - public async Task LoadDataSource_FromList_CopiesItems() - { - // Arrange - var sourceList = new List - { - new(MockSignalRClient.NextId(), "Item 1"), - new(MockSignalRClient.NextId(), "Item 2") - }; - - // Act - await _dataSource.LoadDataSource(sourceList); - - // Assert - Assert.AreEqual(2, _dataSource.Count); - } - - #endregion - - #region Thread Safety Tests - - [TestMethod] - public async Task ConcurrentAdds_AreThreadSafe() - { - // Arrange - var tasks = new List(); - var itemCount = 100; - - // Act - for (int i = 0; i < itemCount; i++) - { - var item = new TestDataItem(MockSignalRClient.NextId(), $"Item {i}"); - tasks.Add(Task.Run(() => _dataSource.Add(item))); - } - - await Task.WhenAll(tasks); - - // Assert - Assert.AreEqual(itemCount, _dataSource.Count); - } - - [TestMethod] - public async Task ConcurrentReadsAndWrites_AreThreadSafe() - { - // Arrange - Pre-populate with items - for (int i = 0; i < 50; i++) - { - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), $"Initial Item {i}")); - } - - var tasks = new List(); - var readCount = 0; - var writeCount = 0; - - // Act - Concurrent reads - for (int i = 0; i < 100; i++) - { - tasks.Add(Task.Run(() => - { - var count = _dataSource.Count; - Interlocked.Increment(ref readCount); - })); - } - - // Act - Concurrent writes - for (int i = 0; i < 50; i++) - { - var item = new TestDataItem(MockSignalRClient.NextId(), $"New Item {i}"); - tasks.Add(Task.Run(() => - { - _dataSource.Add(item); - Interlocked.Increment(ref writeCount); - })); - } - - await Task.WhenAll(tasks); - - // Assert - Assert.AreEqual(100, readCount); - Assert.AreEqual(50, writeCount); - Assert.AreEqual(100, _dataSource.Count); // 50 original + 50 new - } - - [TestMethod] - public async Task ConcurrentAsyncOperations_AreThreadSafe() - { - // Arrange - _mockClient.SetupEchoResponse(AddTag); - - var tasks = new List>(); - - // Act - for (int i = 0; i < 20; i++) - { - var item = new TestDataItem(MockSignalRClient.NextId(), $"Item {i}"); - tasks.Add(_dataSource.Add(item, autoSave: true)); - } - - var results = await Task.WhenAll(tasks); - - // Assert - Assert.AreEqual(20, results.Length); - Assert.AreEqual(20, _dataSource.Count); - } - - [TestMethod] - public void GetEnumerator_ReturnsCopy_SafeForModification() - { - // Arrange - for (int i = 0; i < 10; i++) - { - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")); - } - - // Act & Assert - Should not throw even when modifying during enumeration - var enumeratedItems = new List(); - foreach (var item in _dataSource) - { - enumeratedItems.Add(item); - if (enumeratedItems.Count == 5) - { - // This would throw if we're not using a copy - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "New Item")); - } - } - - Assert.AreEqual(10, enumeratedItems.Count); // Original count - Assert.AreEqual(11, _dataSource.Count); // After modification - } - - [TestMethod] - public async Task ConcurrentRemoves_AreThreadSafe() - { - // Arrange - var items = Enumerable.Range(0, 50) - .Select(i => new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")) - .ToList(); - - foreach (var item in items) - { - _dataSource.Add(item); - } - - // Act - var tasks = items.Select(item => Task.Run(() => _dataSource.Remove(item))).ToList(); - await Task.WhenAll(tasks); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - } - - #endregion - - #region Observable Collection Tests - - [TestMethod] - public void WithObservableCollection_BeginEndUpdate_SuppressesNotifications() - { - // Arrange - var observableDataSource = new TestObservableDataSource(_mockClient, _crudTags); - var innerList = observableDataSource.GetReferenceInnerList(); - var notificationCount = 0; - - innerList.CollectionChanged += (s, e) => notificationCount++; - - // Act - innerList.BeginUpdate(); - for (int i = 0; i < 10; i++) - { - innerList.Add(new TestDataItem(MockSignalRClient.NextId(), $"Item {i}")); - } - innerList.EndUpdate(); - - // Assert - Should only have 1 notification (Reset) instead of 10 - Assert.AreEqual(1, notificationCount); - Assert.AreEqual(10, innerList.Count); - } - - [TestMethod] - public void WithObservableCollection_NestedUpdates_OnlyFiresOnce() - { - // Arrange - var observableDataSource = new TestObservableDataSource(_mockClient, _crudTags); - var innerList = observableDataSource.GetReferenceInnerList(); - var notificationCount = 0; - - innerList.CollectionChanged += (s, e) => notificationCount++; - - // Act - Nested BeginUpdate/EndUpdate - innerList.BeginUpdate(); - innerList.BeginUpdate(); - innerList.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 1")); - innerList.EndUpdate(); // Inner - should not fire - innerList.Add(new TestDataItem(MockSignalRClient.NextId(), "Item 2")); - innerList.EndUpdate(); // Outer - should fire once - - // Assert - Assert.AreEqual(1, notificationCount); - Assert.AreEqual(2, innerList.Count); - } - - #endregion - - #region Edge Cases - - [TestMethod] - public void AddRange_AddsMultipleItems() - { - // Arrange - var items = new List - { - new(MockSignalRClient.NextId(), "Item 1"), - new(MockSignalRClient.NextId(), "Item 2"), - new(MockSignalRClient.NextId(), "Item 3") - }; - - // Act - _dataSource.AddRange(items); - - // Assert - Assert.AreEqual(3, _dataSource.Count); - } - - [TestMethod] - public void IndexOf_ReturnsCorrectIndex() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - // Act - var index = _dataSource.IndexOf(item2); - - // Assert - Assert.AreEqual(1, index); - } - - [TestMethod] - public void IndexOf_NonExisting_ReturnsMinusOne() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Item"); - - // Act - var index = _dataSource.IndexOf(item); - - // Assert - Assert.AreEqual(-1, index); - } - - [TestMethod] - public void TryRemove_RemovesAndReturnsItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.TryRemove(item.Id, out var removedItem); - - // Assert - Assert.IsTrue(result); - Assert.IsNotNull(removedItem); - Assert.AreEqual(item.Id, removedItem.Id); - Assert.AreEqual(0, _dataSource.Count); - } - - [TestMethod] - public void TryGetIndex_ExistingId_ReturnsTrueWithIndex() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - // Act - var result = _dataSource.TryGetIndex(item.Id, out var index); - - // Assert - Assert.IsTrue(result); - Assert.AreEqual(0, index); - } - - [TestMethod] - public void CopyTo_CopiesItemsToArray() - { - // Arrange - var item1 = new TestDataItem(MockSignalRClient.NextId(), "Item 1"); - var item2 = new TestDataItem(MockSignalRClient.NextId(), "Item 2"); - _dataSource.Add(item1); - _dataSource.Add(item2); - - var array = new TestDataItem[2]; - - // Act - _dataSource.CopyTo(array); - - // Assert - Assert.AreEqual(item1.Id, array[0].Id); - Assert.AreEqual(item2.Id, array[1].Id); - } - - [TestMethod] - public void SetWorkingReferenceList_SetsNewInnerList() - { - // Arrange - var newList = new List - { - new(MockSignalRClient.NextId(), "Item 1"), - new(MockSignalRClient.NextId(), "Item 2") - }; - - // Act - _dataSource.SetWorkingReferenceList(newList); - - // Assert - Assert.IsTrue(_dataSource.HasWorkingReferenceList); - Assert.AreEqual(2, _dataSource.Count); - Assert.AreSame(newList, _dataSource.GetReferenceInnerList()); - } - - [TestMethod] - public void SetWorkingReferenceList_WithNull_DoesNothing() - { - // Act - _dataSource.SetWorkingReferenceList(null); - - // Assert - Assert.IsFalse(_dataSource.HasWorkingReferenceList); - } - - [TestMethod] - public void AsReadOnly_ReturnsReadOnlyCollection() - { - // Arrange - _dataSource.Add(new TestDataItem(MockSignalRClient.NextId(), "Item")); - - // Act - var readOnly = _dataSource.AsReadOnly(); - - // Assert - Assert.IsNotNull(readOnly); - Assert.AreEqual(1, readOnly.Count); - } - - #endregion - - #region Event Tests - - [TestMethod] - public async Task OnDataSourceLoaded_IsCalledAfterLoad() - { - // Arrange - var loadedEventCalled = false; - _dataSource.OnDataSourceLoaded = () => - { - loadedEventCalled = true; - return Task.CompletedTask; - }; - - _mockClient.SetupResponse(GetAllTag, new List()); - - // Act - await _dataSource.LoadDataSource(); - - // Assert - Assert.IsTrue(loadedEventCalled); - } - - [TestMethod] - public async Task OnDataSourceItemChanged_IsCalledAfterLoadItem() - { - // Arrange - TestDataItem? changedItem = null; - TrackingState? changedState = null; - - _dataSource.OnDataSourceItemChanged = args => - { - changedItem = args.Item; - changedState = args.TrackingState; - return Task.CompletedTask; - }; - - var id = MockSignalRClient.NextId(); - var item = new TestDataItem(id, "Test Item"); - _mockClient.SetupResponse(GetItemTag, item); - - // Act - await _dataSource.LoadItem(id); - - // Assert - Assert.IsNotNull(changedItem); - Assert.AreEqual(id, changedItem.Id); - Assert.AreEqual(TrackingState.Get, changedState); - } - - [TestMethod] - public async Task IsSyncing_IsTrue_DuringSaveChanges() - { - // Arrange - var wasSyncing = false; - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - - _mockClient.SetupResponse(AddTag, req => - { - wasSyncing = _dataSource.IsSyncing; - return req!; - }); - - // Act - await _dataSource.SaveChanges(); - - // Assert - Assert.IsTrue(wasSyncing); - Assert.IsFalse(_dataSource.IsSyncing); // Should be false after completion - } - - [TestMethod] - public async Task OnSyncingStateChanged_FiresCorrectly() - { - // Arrange - var syncStates = new List(); - _dataSource.OnSyncingStateChanged += state => syncStates.Add(state); - - var item = new TestDataItem(MockSignalRClient.NextId(), "Test Item"); - _dataSource.Add(item); - _mockClient.SetupEchoResponse(AddTag); - - // Act - await _dataSource.SaveChanges(); - - // Assert - Assert.AreEqual(2, syncStates.Count); - Assert.IsTrue(syncStates[0]); // Started - Assert.IsFalse(syncStates[1]); // Ended - } - - #endregion - - #region IList Interface Tests - - [TestMethod] - public void IList_Add_AddsItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); - var list = (System.Collections.IList)_dataSource; - - // Act - var index = list.Add(item); - - // Assert - Assert.AreEqual(0, index); - Assert.AreEqual(1, _dataSource.Count); - } - - [TestMethod] - public void IList_Contains_ReturnsCorrectly() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); - _dataSource.Add(item); - var list = (System.Collections.IList)_dataSource; - - // Act & Assert - Assert.IsTrue(list.Contains(item)); - Assert.IsFalse(list.Contains(new TestDataItem(MockSignalRClient.NextId(), "Other"))); - } - - [TestMethod] - public void IList_IndexOf_ReturnsCorrectIndex() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); - _dataSource.Add(item); - var list = (System.Collections.IList)_dataSource; - - // Act - var index = list.IndexOf(item); - - // Assert - Assert.AreEqual(0, index); - } - - [TestMethod] - public void IList_Remove_RemovesItem() - { - // Arrange - var item = new TestDataItem(MockSignalRClient.NextId(), "Test"); - _dataSource.Add(item); - var list = (System.Collections.IList)_dataSource; - - // Act - list.Remove(item); - - // Assert - Assert.AreEqual(0, _dataSource.Count); - } - - #endregion - - #region Context and Filter Tests - - [TestMethod] - public async Task LoadDataSource_WithContextIds_PassesContextToSignalR() - { - // Arrange - var contextIds = new object[] { 123, "SomeFilter" }; - var dataSourceWithContext = new TestDataSource(_mockClient, _crudTags, contextIds); - _mockClient.SetupResponse(GetAllTag, new List()); - - // Act - await dataSourceWithContext.LoadDataSource(); - - // Assert - Assert.AreEqual(1, _mockClient.GetSentMessageCount(GetAllTag)); - } - - [TestMethod] - public async Task LoadDataSource_WithFilterText_PassesFilterToSignalR() - { - // Arrange - _dataSource.FilterText = "MyFilter"; - _mockClient.SetupResponse(GetAllTag, new List()); - - // Act - await _dataSource.LoadDataSource(); - - // Assert - Assert.AreEqual(1, _mockClient.GetSentMessageCount(GetAllTag)); - } - - #endregion -} diff --git a/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs b/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs deleted file mode 100644 index 2490210..0000000 --- a/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs +++ /dev/null @@ -1,1067 +0,0 @@ -using AyCode.Core.Tests.TestModels; -using AyCode.Core.Extensions; -using AyCode.Services.SignalRs; -using MessagePack; - -namespace AyCode.Services.Server.Tests.SignalRs; - -/// -/// Base class for ProcessOnReceiveMessage tests. -/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage. -/// Uses shared DTOs from AyCode.Core.Tests.TestModels. -/// Derived classes specify the serializer type (JSON or Binary). -/// -public abstract class ProcessOnReceiveMessageTestsBase -{ - protected abstract AcSerializerType SerializerType { get; } - - protected TestableSignalRHub _hub = null!; - protected TestSignalRService _service = null!; - - [TestInitialize] - public void Setup() - { - _hub = new TestableSignalRHub(); - _service = new TestSignalRService(); - _hub.SetSerializerType(SerializerType); - _hub.RegisterService(_service); - } - - [TestCleanup] - public void Cleanup() - { - _hub.Reset(); - _service.Reset(); - } - - #region Single Primitive Parameter Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_SingleInt_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message); - - // Assert - Assert.IsTrue(_service.SingleIntMethodCalled); - Assert.AreEqual(42, _service.ReceivedInt); - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_BoolTrue_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(true); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolParam, message); - - // Assert - Assert.IsTrue(_service.BoolMethodCalled); - Assert.IsTrue(_service.ReceivedBool); - - var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); - Assert.IsTrue(responseData, "Response should be true"); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_BoolFalse_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(false); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolParam, message); - - // Assert - Assert.IsTrue(_service.BoolMethodCalled); - Assert.IsFalse(_service.ReceivedBool); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_String_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage("Hello SignalR!"); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringParam, message); - - // Assert - Assert.IsTrue(_service.StringMethodCalled); - Assert.AreEqual("Hello SignalR!", _service.ReceivedString); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Guid_DeserializesCorrectly() - { - // Arrange - var testGuid = Guid.NewGuid(); - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(testGuid); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidParam, message); - - // Assert - Assert.IsTrue(_service.GuidMethodCalled); - Assert.AreEqual(testGuid, _service.ReceivedGuid); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Enum_DeserializesCorrectly() - { - // Arrange - using shared TestStatus enum - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(TestStatus.Active); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.EnumParam, message); - - // Assert - Assert.IsTrue(_service.EnumMethodCalled); - Assert.AreEqual(TestStatus.Active, _service.ReceivedEnum); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Decimal_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(123.456m); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DecimalParam, message); - - // Assert - Assert.IsTrue(_service.DecimalMethodCalled); - Assert.AreEqual(123.456m, _service.ReceivedDecimal); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_DateTime_DeserializesCorrectly() - { - // Arrange - var testDate = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc); - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(testDate); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DateTimeParam, message); - - // Assert - Assert.IsTrue(_service.DateTimeMethodCalled); - Assert.AreEqual(testDate, _service.ReceivedDateTime); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Double_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(3.14159265359); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DoubleParam, message); - - // Assert - Assert.IsTrue(_service.DoubleMethodCalled); - Assert.IsNotNull(_service.ReceivedDouble); - Assert.IsTrue(Math.Abs(_service.ReceivedDouble.Value - 3.14159265359) < 0.0000000001); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Long_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(9223372036854775807L); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.LongParam, message); - - // Assert - Assert.IsTrue(_service.LongMethodCalled); - Assert.AreEqual(9223372036854775807L, _service.ReceivedLong); - } - - #endregion - - #region Multiple Primitive Parameters Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_TwoInts_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(10, 20); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TwoIntParams, message); - - // Assert - Assert.IsTrue(_service.TwoIntMethodCalled); - Assert.AreEqual((10, 20), _service.ReceivedTwoInts); - - var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); - Assert.AreEqual(30, responseData, "Sum should be 30"); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_MultipleTypes_DeserializesCorrectly() - { - // Arrange - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(true, "test", 123); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.MultipleTypesParams, message); - - // Assert - Assert.IsTrue(_service.MultipleTypesMethodCalled); - Assert.AreEqual((true, "test", 123), _service.ReceivedMultipleTypes); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_FiveParams_DeserializesCorrectly() - { - // Arrange - var testGuid = Guid.NewGuid(); - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(42, "hello", true, testGuid, 99.99m); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.FiveParams, message); - - // Assert - Assert.IsTrue(_service.FiveParamsMethodCalled); - Assert.IsNotNull(_service.ReceivedFiveParams); - Assert.AreEqual(42, _service.ReceivedFiveParams.Value.Item1); - Assert.AreEqual("hello", _service.ReceivedFiveParams.Value.Item2); - Assert.AreEqual(true, _service.ReceivedFiveParams.Value.Item3); - Assert.AreEqual(testGuid, _service.ReceivedFiveParams.Value.Item4); - Assert.AreEqual(99.99m, _service.ReceivedFiveParams.Value.Item5); - } - - #endregion - - #region Complex Object Tests (using shared DTOs) - - [TestMethod] - public async Task ProcessOnReceiveMessage_TestOrderItem_DeserializesCorrectly() - { - // Arrange - using shared TestOrderItem from Core.Tests - var item = new TestOrderItem - { - Id = 1, - ProductName = "Test Product", - Quantity = 10, - UnitPrice = 99.99m, - Status = TestStatus.Active - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(item); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemParam, message); - - // Assert - Assert.IsTrue(_service.TestOrderItemMethodCalled); - Assert.IsNotNull(_service.ReceivedTestOrderItem); - Assert.AreEqual(1, _service.ReceivedTestOrderItem.Id); - Assert.AreEqual("Test Product", _service.ReceivedTestOrderItem.ProductName); - Assert.AreEqual(10, _service.ReceivedTestOrderItem.Quantity); - Assert.AreEqual(99.99m, _service.ReceivedTestOrderItem.UnitPrice); - Assert.AreEqual(TestStatus.Active, _service.ReceivedTestOrderItem.Status); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_TestOrder_WithNestedItems_DeserializesCorrectly() - { - // Arrange - using shared TestOrder with nested items - var order = new TestOrder - { - Id = 100, - OrderNumber = "ORD-001", - Status = TestStatus.Active, - TotalAmount = 500.00m, - Items = - [ - new TestOrderItem { Id = 1, ProductName = "Item A", Quantity = 2 }, - new TestOrderItem { Id = 2, ProductName = "Item B", Quantity = 3 } - ] - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(order); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderParam, message); - - // Assert - Assert.IsTrue(_service.TestOrderMethodCalled); - Assert.IsNotNull(_service.ReceivedTestOrder); - Assert.AreEqual(100, _service.ReceivedTestOrder.Id); - Assert.AreEqual("ORD-001", _service.ReceivedTestOrder.OrderNumber); - Assert.AreEqual(2, _service.ReceivedTestOrder.Items.Count); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_SharedTag_IIdType_DeserializesCorrectly() - { - // Arrange - using shared SharedTag (IId type) - var tag = new SharedTag - { - Id = 1, - Name = "Important", - Color = "#FF0000", - Priority = 1, - IsActive = true - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(tag); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SharedTagParam, message); - - // Assert - Assert.IsTrue(_service.SharedTagMethodCalled); - Assert.IsNotNull(_service.ReceivedSharedTag); - Assert.AreEqual(1, _service.ReceivedSharedTag.Id); - Assert.AreEqual("Important", _service.ReceivedSharedTag.Name); - Assert.AreEqual("#FF0000", _service.ReceivedSharedTag.Color); - } - - #endregion - - #region No Parameters Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_NoParams_InvokesMethod() - { - // Arrange - var message = SignalRTestHelper.CreateEmptyMessage(); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, message); - - // Assert - Assert.IsTrue(_service.NoParamsMethodCalled); - SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); - } - - #endregion - - #region Simple Collection Parameter Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_IntArray_DeserializesCorrectly() - { - // Arrange - Arrays are complex objects (not ValueType), use CreateComplexObjectMessage - var values = new[] { 1, 2, 3, 4, 5 }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); - - // Assert - Assert.IsTrue(_service.IntArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedIntArray); - CollectionAssert.AreEqual(values, _service.ReceivedIntArray); - - // Verify response (doubled values) - var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); - Assert.IsNotNull(responseData); - CollectionAssert.AreEqual(new[] { 2, 4, 6, 8, 10 }, responseData); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_GuidArray_DeserializesCorrectly() - { - // Arrange - var guids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; - var message = SignalRTestHelper.CreateComplexObjectMessage(guids); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); - - // Assert - Assert.IsTrue(_service.GuidArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedGuidArray); - Assert.AreEqual(3, _service.ReceivedGuidArray.Length); - CollectionAssert.AreEqual(guids, _service.ReceivedGuidArray); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_StringList_DeserializesCorrectly() - { - // Arrange - var items = new List { "apple", "banana", "cherry" }; - var message = SignalRTestHelper.CreateComplexObjectMessage(items); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringListParam, message); - - // Assert - Assert.IsTrue(_service.StringListMethodCalled); - Assert.IsNotNull(_service.ReceivedStringList); - CollectionAssert.AreEqual(items, _service.ReceivedStringList); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_IntList_DeserializesCorrectly() - { - // Arrange - var numbers = new List { 10, 20, 30 }; - var message = SignalRTestHelper.CreateComplexObjectMessage(numbers); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntListParam, message); - - // Assert - Assert.IsTrue(_service.IntListMethodCalled); - Assert.IsNotNull(_service.ReceivedIntList); - CollectionAssert.AreEqual(numbers, _service.ReceivedIntList); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_BoolArray_DeserializesCorrectly() - { - // Arrange - var flags = new[] { true, false, true, true }; - var message = SignalRTestHelper.CreateComplexObjectMessage(flags); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolArrayParam, message); - - // Assert - Assert.IsTrue(_service.BoolArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedBoolArray); - CollectionAssert.AreEqual(flags, _service.ReceivedBoolArray); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyIntArray_DeserializesCorrectly() - { - // Arrange - var values = Array.Empty(); - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); - - // Assert - Assert.IsTrue(_service.IntArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedIntArray); - Assert.AreEqual(0, _service.ReceivedIntArray.Length); - } - - #endregion - - #region Complex Collection Parameter Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_TestOrderItemList_DeserializesCorrectly() - { - // Arrange - using shared TestOrderItem list - var items = new List - { - new() { Id = 1, ProductName = "First", Quantity = 1 }, - new() { Id = 2, ProductName = "Second", Quantity = 2 }, - new() { Id = 3, ProductName = "Third", Quantity = 3 } - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(items); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemListParam, message); - - // Assert - Assert.IsTrue(_service.TestOrderItemListMethodCalled); - Assert.AreEqual(3, _service.ReceivedTestOrderItemList?.Count); - Assert.AreEqual("First", _service.ReceivedTestOrderItemList?[0].ProductName); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_NestedList_DeserializesCorrectly() - { - // Arrange - var nestedList = new List> - { - new() { 1, 2, 3 }, - new() { 4, 5 }, - new() { 6, 7, 8, 9 } - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(nestedList); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NestedListParam, message); - - // Assert - Assert.IsTrue(_service.NestedListMethodCalled); - Assert.IsNotNull(_service.ReceivedNestedList); - Assert.AreEqual(3, _service.ReceivedNestedList.Count); - CollectionAssert.AreEqual(new List { 1, 2, 3 }, _service.ReceivedNestedList[0]); - } - - #endregion - - #region Mixed Parameter Tests (Primitive + Complex) - - [TestMethod] - public async Task ProcessOnReceiveMessage_IntAndDto_DeserializesCorrectly() - { - // Arrange - var item = new TestOrderItem { Id = 10, ProductName = "Test", Quantity = 5 }; - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(42, item); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntAndDtoParam, message); - - // Assert - Assert.IsTrue(_service.IntAndDtoMethodCalled); - Assert.IsNotNull(_service.ReceivedIntAndDto); - Assert.AreEqual(42, _service.ReceivedIntAndDto.Value.Item1); - Assert.AreEqual("Test", _service.ReceivedIntAndDto.Value.Item2?.ProductName); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_DtoAndList_DeserializesCorrectly() - { - // Arrange - var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 1 }; - var numbers = new List { 1, 2, 3 }; - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(item, numbers); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DtoAndListParam, message); - - // Assert - Assert.IsTrue(_service.DtoAndListMethodCalled); - Assert.IsNotNull(_service.ReceivedDtoAndList); - Assert.AreEqual("Test", _service.ReceivedDtoAndList.Value.Item1?.ProductName); - CollectionAssert.AreEqual(numbers, _service.ReceivedDtoAndList.Value.Item2); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_ThreeComplexParams_DeserializesCorrectly() - { - // Arrange - var item = new TestOrderItem { Id = 1, ProductName = "Product", Quantity = 1 }; - var tags = new List { "tag1", "tag2" }; - var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(item, tags, sharedTag); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.ThreeComplexParams, message); - - // Assert - Assert.IsTrue(_service.ThreeComplexParamsMethodCalled); - Assert.IsNotNull(_service.ReceivedThreeComplexParams); - Assert.AreEqual("Product", _service.ReceivedThreeComplexParams.Value.Item1?.ProductName); - Assert.AreEqual(2, _service.ReceivedThreeComplexParams.Value.Item2?.Count); - Assert.AreEqual("Shared", _service.ReceivedThreeComplexParams.Value.Item3?.Name); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_MixedWithArray_DeserializesCorrectly() - { - // Arrange - Multiple params: bool, int[], string - // When ParamCount > 1, uses IdMessage format regardless of types - var flag = true; - var numbers = new[] { 1, 2, 3 }; - var text = "hello"; - var message = SignalRTestHelper.CreatePrimitiveParamsMessage(flag, numbers, text); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.MixedWithArrayParam, message); - - // Assert - Assert.IsTrue(_service.MixedWithArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedMixedWithArray); - Assert.AreEqual(true, _service.ReceivedMixedWithArray.Value.Item1); - CollectionAssert.AreEqual(numbers, _service.ReceivedMixedWithArray.Value.Item2); - Assert.AreEqual("hello", _service.ReceivedMixedWithArray.Value.Item3); - } - - #endregion - - #region Error Handling Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_UnknownTag_InvokesNotFoundCallback() - { - // Arrange - const int unknownTag = 9999; - var message = SignalRTestHelper.CreateEmptyMessage(); - - // Act - await _hub.InvokeProcessOnReceiveMessage(unknownTag, message); - - // Assert - Assert.IsTrue(_hub.WasNotFoundCallbackInvoked); - Assert.IsNotNull(_hub.NotFoundTagName); - - // Should still send error response - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], unknownTag); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_MethodThrows_ReturnsErrorResponse() - { - // Arrange - var message = SignalRTestHelper.CreateEmptyMessage(); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.ThrowsException, message); - - // Assert - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.ThrowsException); - Assert.IsTrue(_hub.Logger.HasErrorLogs, "Should have logged error"); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyMessage_LogsWarning() - { - // Arrange - byte[] emptyMessage = []; - - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, emptyMessage); - - // Assert - Assert.IsTrue(_hub.Logger.HasWarningLogs, "Should have warning for empty message"); - } - - #endregion - - #region Response Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_Success_SendsToCaller() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message); - - // Assert - Assert.AreEqual(1, _hub.SentMessages.Count); - Assert.AreEqual(SendTarget.Caller, _hub.SentMessages[0].Target); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_WithRequestId_IncludesRequestIdInResponse() - { - // Arrange - var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); - const int requestId = 12345; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message, requestId); - - // Assert - Assert.AreEqual(requestId, _hub.SentMessages[0].RequestId); - } - - #endregion - - #region Empty/Null Message Edge Cases - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyBytes_WithNoParams_Succeeds() - { - // Arrange - empty byte array for method with no parameters - byte[] emptyMessage = []; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, emptyMessage); - - // Assert - should succeed because method has no params - Assert.IsTrue(_service.NoParamsMethodCalled); - Assert.IsTrue(_hub.Logger.HasWarningLogs, "Should have warning for empty message"); - SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyBytes_WithIntParam_ReturnsError() - { - // Arrange - empty byte array for method expecting int parameter - byte[] emptyMessage = []; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, emptyMessage); - - // Assert - should return error response, not crash - Assert.IsFalse(_service.SingleIntMethodCalled, "Method should not be called with empty message"); - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); - Assert.IsTrue(_hub.Logger.HasErrorLogs || _hub.Logger.HasWarningLogs); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyBytes_WithComplexParam_ReturnsError() - { - // Arrange - empty byte array for method expecting TestOrderItem parameter - byte[] emptyMessage = []; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemParam, emptyMessage); - - // Assert - should return error response, not crash - Assert.IsFalse(_service.TestOrderItemMethodCalled, "Method should not be called with empty message"); - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.TestOrderItemParam); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_NullMessage_WithNoParams_Succeeds() - { - // Arrange - null message for method with no parameters - byte[]? nullMessage = null; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, nullMessage); - - // Assert - should succeed because method has no params - Assert.IsTrue(_service.NoParamsMethodCalled); - SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_NullMessage_WithIntParam_ReturnsError() - { - // Arrange - null message for method expecting int parameter - byte[]? nullMessage = null; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, nullMessage); - - // Assert - should return error response, not crash - Assert.IsFalse(_service.SingleIntMethodCalled, "Method should not be called with null message"); - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyBytes_WithMultipleParams_ReturnsError() - { - // Arrange - empty byte array for method expecting multiple parameters - byte[] emptyMessage = []; - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TwoIntParams, emptyMessage); - - // Assert - should return error response, not crash - Assert.IsFalse(_service.TwoIntMethodCalled, "Method should not be called with empty message"); - Assert.AreEqual(1, _hub.SentMessages.Count); - SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.TwoIntParams); - } - - #endregion - - #region Extended Array Parameter Deserialization Tests - - [TestMethod] - public async Task ProcessOnReceiveMessage_LongArray_DeserializesCorrectly() - { - // Arrange - var values = new[] { 1L, 9223372036854775807L, -9223372036854775808L, 0L }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.LongArrayParam, message); - - // Assert - Assert.IsTrue(_service.LongArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedLongArray); - Assert.AreEqual(4, _service.ReceivedLongArray.Length); - Assert.AreEqual(9223372036854775807L, _service.ReceivedLongArray[1]); - Assert.AreEqual(-9223372036854775808L, _service.ReceivedLongArray[2]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_DecimalArray_DeserializesCorrectly() - { - // Arrange - var values = new[] { 0.01m, 99.99m, 123456.789m, -999.99m }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DecimalArrayParam, message); - - // Assert - Assert.IsTrue(_service.DecimalArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedDecimalArray); - Assert.AreEqual(4, _service.ReceivedDecimalArray.Length); - Assert.AreEqual(0.01m, _service.ReceivedDecimalArray[0]); - Assert.AreEqual(99.99m, _service.ReceivedDecimalArray[1]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_DateTimeArray_DeserializesCorrectly() - { - // Arrange - var values = new[] - { - new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), - DateTime.MinValue, - new DateTime(2000, 6, 15, 12, 30, 45, DateTimeKind.Utc) - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DateTimeArrayParam, message); - - // Assert - Assert.IsTrue(_service.DateTimeArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedDateTimeArray); - Assert.AreEqual(4, _service.ReceivedDateTimeArray.Length); - Assert.AreEqual(2024, _service.ReceivedDateTimeArray[0].Year); - Assert.AreEqual(12, _service.ReceivedDateTimeArray[1].Month); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EnumArray_DeserializesCorrectly() - { - // Arrange - using shared TestStatus enum - var values = new[] { TestStatus.Pending, TestStatus.Active, TestStatus.Processing, TestStatus.Shipped }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.EnumArrayParam, message); - - // Assert - Assert.IsTrue(_service.EnumArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedEnumArray); - Assert.AreEqual(4, _service.ReceivedEnumArray.Length); - Assert.AreEqual(TestStatus.Pending, _service.ReceivedEnumArray[0]); - Assert.AreEqual(TestStatus.Shipped, _service.ReceivedEnumArray[3]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_DoubleArray_DeserializesCorrectly() - { - // Arrange - var values = new[] { 3.14159265359, -273.15, 0.0, double.MaxValue, double.MinValue }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DoubleArrayParam, message); - - // Assert - Assert.IsTrue(_service.DoubleArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedDoubleArray); - Assert.AreEqual(5, _service.ReceivedDoubleArray.Length); - Assert.IsTrue(Math.Abs(_service.ReceivedDoubleArray[0] - 3.14159265359) < 0.0000001); - Assert.AreEqual(-273.15, _service.ReceivedDoubleArray[1]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_SharedTagArray_ComplexObjectArray_DeserializesCorrectly() - { - // Arrange - array of IId complex objects - var tags = new[] - { - new SharedTag { Id = 1, Name = "Tag1", Color = "#FF0000", Priority = 1 }, - new SharedTag { Id = 2, Name = "Tag2", Color = "#00FF00", Priority = 2 }, - new SharedTag { Id = 3, Name = "Tag3", Color = "#0000FF", Priority = 3 } - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(tags); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SharedTagArrayParam, message); - - // Assert - Assert.IsTrue(_service.SharedTagArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedSharedTagArray); - Assert.AreEqual(3, _service.ReceivedSharedTagArray.Length); - Assert.AreEqual("Tag1", _service.ReceivedSharedTagArray[0].Name); - Assert.AreEqual("#00FF00", _service.ReceivedSharedTagArray[1].Color); - Assert.AreEqual(3, _service.ReceivedSharedTagArray[2].Priority); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_Dictionary_DeserializesCorrectly() - { - // Arrange - var dict = new Dictionary - { - { "apple", 1 }, - { "banana", 2 }, - { "cherry", 3 } - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(dict); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DictionaryParam, message); - - // Assert - Assert.IsTrue(_service.DictionaryMethodCalled); - Assert.IsNotNull(_service.ReceivedDictionary); - Assert.AreEqual(3, _service.ReceivedDictionary.Count); - Assert.AreEqual(1, _service.ReceivedDictionary["apple"]); - Assert.AreEqual(2, _service.ReceivedDictionary["banana"]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_EmptyGuidArray_DeserializesCorrectly() - { - // Arrange - edge case: empty Guid array - var guids = Array.Empty(); - var message = SignalRTestHelper.CreateComplexObjectMessage(guids); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); - - // Assert - Assert.IsTrue(_service.GuidArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedGuidArray); - Assert.AreEqual(0, _service.ReceivedGuidArray.Length); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_SingleElementArray_DeserializesCorrectly() - { - // Arrange - edge case: single element array - var values = new[] { 42 }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); - - // Assert - Assert.IsTrue(_service.IntArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedIntArray); - Assert.AreEqual(1, _service.ReceivedIntArray.Length); - Assert.AreEqual(42, _service.ReceivedIntArray[0]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_LargeArray_DeserializesCorrectly() - { - // Arrange - performance edge case: large array - var values = Enumerable.Range(1, 1000).ToArray(); - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); - - // Assert - Assert.IsTrue(_service.IntArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedIntArray); - Assert.AreEqual(1000, _service.ReceivedIntArray.Length); - Assert.AreEqual(1, _service.ReceivedIntArray[0]); - Assert.AreEqual(1000, _service.ReceivedIntArray[999]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_ComplexObjectListWithNestedCollections_DeserializesCorrectly() - { - // Arrange - TestOrder contains nested Items list - var orders = new List - { - new() { Id = 1, ProductName = "Product A", Quantity = 10, UnitPrice = 9.99m, Status = TestStatus.Active }, - new() { Id = 2, ProductName = "Product B", Quantity = 5, UnitPrice = 19.99m, Status = TestStatus.Processing }, - new() { Id = 3, ProductName = "Product C", Quantity = 1, UnitPrice = 99.99m, Status = TestStatus.Shipped } - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(orders); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemListParam, message); - - // Assert - Assert.IsTrue(_service.TestOrderItemListMethodCalled); - Assert.IsNotNull(_service.ReceivedTestOrderItemList); - Assert.AreEqual(3, _service.ReceivedTestOrderItemList.Count); - Assert.AreEqual("Product A", _service.ReceivedTestOrderItemList[0].ProductName); - Assert.AreEqual(19.99m, _service.ReceivedTestOrderItemList[1].UnitPrice); - Assert.AreEqual(TestStatus.Shipped, _service.ReceivedTestOrderItemList[2].Status); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_StringArrayWithSpecialChars_DeserializesCorrectly() - { - // Arrange - strings with special characters - var values = new List - { - "normal", - "with spaces", - "with\"quotes\"", - "with\nnewline", - "with\ttab", - "unicode: áéíóú", - "" // empty string - }; - var message = SignalRTestHelper.CreateComplexObjectMessage(values); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringListParam, message); - - // Assert - Assert.IsTrue(_service.StringListMethodCalled); - Assert.IsNotNull(_service.ReceivedStringList); - Assert.AreEqual(7, _service.ReceivedStringList.Count); - Assert.AreEqual("normal", _service.ReceivedStringList[0]); - Assert.AreEqual("with spaces", _service.ReceivedStringList[1]); - Assert.AreEqual("unicode: áéíóú", _service.ReceivedStringList[5]); - Assert.AreEqual("", _service.ReceivedStringList[6]); - } - - [TestMethod] - public async Task ProcessOnReceiveMessage_GuidArrayWithEmptyGuid_DeserializesCorrectly() - { - // Arrange - includes Guid.Empty - var guids = new[] { Guid.NewGuid(), Guid.Empty, Guid.NewGuid() }; - var message = SignalRTestHelper.CreateComplexObjectMessage(guids); - - // Act - await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); - - // Assert - Assert.IsTrue(_service.GuidArrayMethodCalled); - Assert.IsNotNull(_service.ReceivedGuidArray); - Assert.AreEqual(3, _service.ReceivedGuidArray.Length); - Assert.AreEqual(Guid.Empty, _service.ReceivedGuidArray[1]); - } - - [TestMethod] - public void CreateComplexObjectMessage_Dictionary_ProducesCorrectJson() - { - // Arrange - var dict = new Dictionary - { - { "apple", 1 }, - { "banana", 2 }, - { "cherry", 3 } - }; - - // Act - var message = SignalRTestHelper.CreateComplexObjectMessage(dict); - var deserialized = MessagePackSerializer.Deserialize>( - message, MessagePackSerializerOptions.Standard); - - // Assert - Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null"); - Assert.IsTrue(deserialized.PostDataJson.Contains("apple"), - $"PostDataJson should contain 'apple'. Actual: {deserialized.PostDataJson}"); - Assert.IsTrue(deserialized.PostDataJson.StartsWith("{"), - $"PostDataJson should start with {{. Actual first char: {deserialized.PostDataJson[0]}"); - } - - #endregion -} - -/// -/// Runs all ProcessOnReceiveMessage tests with JSON serialization. -/// -[TestClass] -public class ProcessOnReceiveMessageTests_Json : ProcessOnReceiveMessageTestsBase -{ - protected override AcSerializerType SerializerType => AcSerializerType.Json; -} - -/// -/// Runs all ProcessOnReceiveMessage tests with Binary serialization. -/// -[TestClass] -public class ProcessOnReceiveMessageTests_Binary : ProcessOnReceiveMessageTestsBase -{ - protected override AcSerializerType SerializerType => AcSerializerType.Binary; -} diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDataSourceTests.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDataSourceTests.cs new file mode 100644 index 0000000..a1ea8f6 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDataSourceTests.cs @@ -0,0 +1,172 @@ +using AyCode.Core.Enums; +using AyCode.Core.Extensions; +using AyCode.Core.Helpers; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; + +namespace AyCode.Services.Server.Tests.SignalRs; + +#region DataSource Implementations + +public class TestOrderItemListDataSource : AcSignalRDataSource> +{ + public TestOrderItemListDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags) + : base(signalRClient, crudTags) { } +} + +public class TestOrderItemObservableDataSource : AcSignalRDataSource> +{ + public TestOrderItemObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags) + : base(signalRClient, crudTags) { } +} + +#endregion + +#region Abstract Test Base + +/// +/// Base class for SignalR DataSource tests. +/// Derived classes specify the serializer type and collection type. +/// +/// The concrete DataSource type +/// The inner list type (List or AcObservableCollection) +public abstract class SignalRDataSourceTestBase + where TDataSource : AcSignalRDataSource + where TIList : class, IList +{ + protected abstract AcSerializerOptions SerializerOption { get; } + protected abstract TDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags); + + protected TestLogger _logger = null!; + protected TestableSignalRHub2 _hub = null!; + protected TestableSignalRClient2 _client = null!; + protected TestSignalRService2 _service = null!; + protected SignalRCrudTags _crudTags = null!; + + [TestInitialize] + public void Setup() + { + _logger = new TestLogger(); + _hub = new TestableSignalRHub2(); + _service = new TestSignalRService2(); + _client = new TestableSignalRClient2(_hub, _logger); + + _hub.SetSerializerType(SerializerOption); + _hub.RegisterService(_service, _client); + + _crudTags = new SignalRCrudTags( + TestSignalRTags.DataSourceGetAll, + TestSignalRTags.DataSourceGetItem, + TestSignalRTags.DataSourceAdd, + TestSignalRTags.DataSourceUpdate, + TestSignalRTags.DataSourceRemove + ); + } + + #region Load Tests + + [TestMethod] + public async Task LoadDataSource_ReturnsAllItems() + { + var dataSource = CreateDataSource(_client, _crudTags); + + await dataSource.LoadDataSource(); + + Assert.AreEqual(3, dataSource.Count); + Assert.AreEqual("Product A", dataSource[0].ProductName); + } + + [TestMethod] + public async Task LoadItem_ReturnsSingleItem() + { + var dataSource = CreateDataSource(_client, _crudTags); + + var result = await dataSource.LoadItem(2); + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Id); + Assert.AreEqual("Product B", result.ProductName); + } + + #endregion + + #region Add Tests + + [TestMethod] + public async Task Add_WithAutoSave_AddsItem() + { + var dataSource = CreateDataSource(_client, _crudTags); + var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m }; + + var result = await dataSource.Add(newItem, autoSave: true); + + Assert.AreEqual(1, dataSource.Count); + Assert.AreEqual("New Product", result.ProductName); + } + + [TestMethod] + public void Add_WithoutAutoSave_AddsToTrackingOnly() + { + var dataSource = CreateDataSource(_client, _crudTags); + var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" }; + + dataSource.Add(newItem); + + Assert.AreEqual(1, dataSource.Count); + Assert.AreEqual(1, dataSource.GetTrackingItems().Count); + Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState); + } + + #endregion + + #region SaveChanges Tests + + [TestMethod] + public async Task SaveChanges_SavesTrackedItems() + { + var dataSource = CreateDataSource(_client, _crudTags); + dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" }); + dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" }); + + var unsaved = await dataSource.SaveChanges(); + + Assert.AreEqual(0, unsaved.Count); + Assert.AreEqual(0, dataSource.GetTrackingItems().Count); + } + + [TestMethod] + public async Task SaveChangesAsync_ClearsTracking() + { + var dataSource = CreateDataSource(_client, _crudTags); + dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" }); + + await dataSource.SaveChangesAsync(); + + Assert.AreEqual(0, dataSource.GetTrackingItems().Count); + } + + #endregion +} + +#endregion + +#region DataSources + +[TestClass] +public class SignalRDataSourceTests_List : SignalRDataSourceTestBase> +{ + protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default; + protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags) + => new(client, crudTags); +} + +[TestClass] +public class SignalRDataSourceTests_Observable : SignalRDataSourceTestBase> +{ + protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default; + + protected override TestOrderItemObservableDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags) + => new(client, crudTags); +} +#endregion diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs index 8b8897a..733af7c 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs @@ -5,18 +5,39 @@ using MessagePack.Resolvers; namespace AyCode.Services.Server.Tests.SignalRs; +/// +/// Captured sent message for assertions. +/// +public record SentMessage( + int MessageTag, + ISignalRMessage Message, + int? RequestId, + SendTarget Target, + string? TargetId = null) +{ + public SignalResponseDataMessage? AsDataResponse => Message as SignalResponseDataMessage; +} + +/// +/// Target of the sent message. +/// +public enum SendTarget +{ + Caller, + Client, + Others, + All, + User, + Group +} + /// /// Helper methods for creating SignalR test messages. -/// Uses the production SignalR types for compatibility with the actual client/server code. /// public static class SignalRTestHelper { private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options; - /// - /// Creates a MessagePack message for parameters using IdMessage format. - /// Each parameter is serialized directly as JSON (no array wrapping). - /// public static byte[] CreatePrimitiveParamsMessage(params object[] values) { var idMessage = new IdMessage(values); @@ -24,18 +45,9 @@ public static class SignalRTestHelper return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); } - /// - /// Creates a MessagePack message for a single primitive parameter. - /// public static byte[] CreateSinglePrimitiveMessage(T value) where T : notnull - { - return CreatePrimitiveParamsMessage(value); - } + => CreatePrimitiveParamsMessage(value); - /// - /// Creates a MessagePack message for a complex object parameter. - /// Uses PostDataJson pattern for single complex objects. - /// public static byte[] CreateComplexObjectMessage(T obj) { var json = obj.ToJson(); @@ -43,73 +55,39 @@ public static class SignalRTestHelper return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); } - /// - /// Creates an empty MessagePack message for parameterless methods. - /// public static byte[] CreateEmptyMessage() { var postMessage = new SignalPostJsonDataMessage(); return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); } - /// - /// Deserialize a SignalResponseJsonMessage from the captured SentMessage. - /// public static T? GetResponseData(SentMessage sentMessage) { - if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null) - { - return jsonResponse.ResponseData.JsonTo(); - } - - if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null) - { - return binaryResponse.ResponseData.BinaryTo(); - } + if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseDataBin != null) + return dataResponse.GetResponseData(); return default; } - /// - /// Gets the response status from either JSON or Binary message. - /// - private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message) - { - return message switch - { - SignalResponseJsonMessage jsonMsg => jsonMsg.Status, - SignalResponseBinaryMessage binaryMsg => binaryMsg.Status, - _ => null - }; - } - - /// - /// Assert that a response was successful. - /// public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag) { - var status = GetResponseStatus(sentMessage.Message); - if (status == null) - throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}"); + if (sentMessage.Message is not SignalResponseDataMessage response) + throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}"); - if (status != SignalResponseStatus.Success) - throw new AssertFailedException($"Expected Success status but got {status}"); + if (response.Status != SignalResponseStatus.Success) + throw new AssertFailedException($"Expected Success status but got {response.Status}"); if (sentMessage.MessageTag != expectedTag) throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); } - /// - /// Assert that a response was an error. - /// public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag) { - var status = GetResponseStatus(sentMessage.Message); - if (status == null) - throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}"); + if (sentMessage.Message is not SignalResponseDataMessage response) + throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}"); - if (status != SignalResponseStatus.Error) - throw new AssertFailedException($"Expected Error status but got {status}"); + if (response.Status != SignalResponseStatus.Error) + throw new AssertFailedException($"Expected Error status but got {response.Status}"); if (sentMessage.MessageTag != expectedTag) throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs deleted file mode 100644 index 548beda..0000000 --- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs +++ /dev/null @@ -1,518 +0,0 @@ -using AyCode.Core.Tests.TestModels; -using AyCode.Services.SignalRs; - -namespace AyCode.Services.Server.Tests.SignalRs; - -/// -/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage. -/// Uses shared DTOs from AyCode.Core.Tests.TestModels. -/// -public class TestSignalRService -{ - #region Captured Values for Assertions - - // Primitive captures - public bool SingleIntMethodCalled { get; private set; } - public int? ReceivedInt { get; private set; } - - public bool TwoIntMethodCalled { get; private set; } - public (int A, int B)? ReceivedTwoInts { get; private set; } - - public bool BoolMethodCalled { get; private set; } - public bool? ReceivedBool { get; private set; } - - public bool StringMethodCalled { get; private set; } - public string? ReceivedString { get; private set; } - - public bool GuidMethodCalled { get; private set; } - public Guid? ReceivedGuid { get; private set; } - - public bool EnumMethodCalled { get; private set; } - public TestStatus? ReceivedEnum { get; private set; } - - public bool NoParamsMethodCalled { get; private set; } - - public bool MultipleTypesMethodCalled { get; private set; } - public (bool, string, int)? ReceivedMultipleTypes { get; private set; } - - // Extended primitives - public bool DecimalMethodCalled { get; private set; } - public decimal? ReceivedDecimal { get; private set; } - - public bool DateTimeMethodCalled { get; private set; } - public DateTime? ReceivedDateTime { get; private set; } - - public bool DoubleMethodCalled { get; private set; } - public double? ReceivedDouble { get; private set; } - - public bool LongMethodCalled { get; private set; } - public long? ReceivedLong { get; private set; } - - // Complex object captures (using shared DTOs) - public bool TestOrderItemMethodCalled { get; private set; } - public TestOrderItem? ReceivedTestOrderItem { get; private set; } - - public bool TestOrderMethodCalled { get; private set; } - public TestOrder? ReceivedTestOrder { get; private set; } - - public bool SharedTagMethodCalled { get; private set; } - public SharedTag? ReceivedSharedTag { get; private set; } - - // Collection captures - public bool IntArrayMethodCalled { get; private set; } - public int[]? ReceivedIntArray { get; private set; } - - public bool GuidArrayMethodCalled { get; private set; } - public Guid[]? ReceivedGuidArray { get; private set; } - - public bool StringListMethodCalled { get; private set; } - public List? ReceivedStringList { get; private set; } - - public bool TestOrderItemListMethodCalled { get; private set; } - public List? ReceivedTestOrderItemList { get; private set; } - - public bool IntListMethodCalled { get; private set; } - public List? ReceivedIntList { get; private set; } - - public bool BoolArrayMethodCalled { get; private set; } - public bool[]? ReceivedBoolArray { get; private set; } - - public bool MixedWithArrayMethodCalled { get; private set; } - public (bool, int[], string)? ReceivedMixedWithArray { get; private set; } - - public bool NestedListMethodCalled { get; private set; } - public List>? ReceivedNestedList { get; private set; } - - // Extended array captures for comprehensive testing - public bool LongArrayMethodCalled { get; private set; } - public long[]? ReceivedLongArray { get; private set; } - - public bool DecimalArrayMethodCalled { get; private set; } - public decimal[]? ReceivedDecimalArray { get; private set; } - - public bool DateTimeArrayMethodCalled { get; private set; } - public DateTime[]? ReceivedDateTimeArray { get; private set; } - - public bool EnumArrayMethodCalled { get; private set; } - public TestStatus[]? ReceivedEnumArray { get; private set; } - - public bool DoubleArrayMethodCalled { get; private set; } - public double[]? ReceivedDoubleArray { get; private set; } - - public bool SharedTagArrayMethodCalled { get; private set; } - public SharedTag[]? ReceivedSharedTagArray { get; private set; } - - public bool DictionaryMethodCalled { get; private set; } - public Dictionary? ReceivedDictionary { get; private set; } - - public bool ObjectArrayMethodCalled { get; private set; } - public object[]? ReceivedObjectArray { get; private set; } - - // Mixed parameter captures - public bool IntAndDtoMethodCalled { get; private set; } - public (int, TestOrderItem?)? ReceivedIntAndDto { get; private set; } - - public bool DtoAndListMethodCalled { get; private set; } - public (TestOrderItem?, List?)? ReceivedDtoAndList { get; private set; } - - public bool ThreeComplexParamsMethodCalled { get; private set; } - public (TestOrderItem?, List?, SharedTag?)? ReceivedThreeComplexParams { get; private set; } - - public bool FiveParamsMethodCalled { get; private set; } - public (int, string?, bool, Guid, decimal)? ReceivedFiveParams { get; private set; } - - #endregion - - #region Primitive Parameter Handlers - - [SignalR(TestSignalRTags.SingleIntParam)] - public string HandleSingleInt(int value) - { - SingleIntMethodCalled = true; - ReceivedInt = value; - return $"Received: {value}"; - } - - [SignalR(TestSignalRTags.TwoIntParams)] - public int HandleTwoInts(int a, int b) - { - TwoIntMethodCalled = true; - ReceivedTwoInts = (a, b); - return a + b; - } - - [SignalR(TestSignalRTags.BoolParam)] - public bool HandleBool(bool loadRelations) - { - BoolMethodCalled = true; - ReceivedBool = loadRelations; - return loadRelations; - } - - [SignalR(TestSignalRTags.StringParam)] - public string HandleString(string text) - { - StringMethodCalled = true; - ReceivedString = text; - return $"Echo: {text}"; - } - - [SignalR(TestSignalRTags.GuidParam)] - public Guid HandleGuid(Guid id) - { - GuidMethodCalled = true; - ReceivedGuid = id; - return id; - } - - [SignalR(TestSignalRTags.EnumParam)] - public TestStatus HandleEnum(TestStatus status) - { - EnumMethodCalled = true; - ReceivedEnum = status; - return status; - } - - [SignalR(TestSignalRTags.NoParams)] - public string HandleNoParams() - { - NoParamsMethodCalled = true; - return "OK"; - } - - [SignalR(TestSignalRTags.MultipleTypesParams)] - public string HandleMultipleTypes(bool flag, string text, int number) - { - MultipleTypesMethodCalled = true; - ReceivedMultipleTypes = (flag, text, number); - return $"{flag}-{text}-{number}"; - } - - [SignalR(TestSignalRTags.ThrowsException)] - public void HandleThrowsException() - { - throw new InvalidOperationException("Test exception"); - } - - [SignalR(TestSignalRTags.DecimalParam)] - public decimal HandleDecimal(decimal value) - { - DecimalMethodCalled = true; - ReceivedDecimal = value; - return value * 2; - } - - [SignalR(TestSignalRTags.DateTimeParam)] - public DateTime HandleDateTime(DateTime dateTime) - { - DateTimeMethodCalled = true; - ReceivedDateTime = dateTime; - return dateTime; - } - - [SignalR(TestSignalRTags.DoubleParam)] - public double HandleDouble(double value) - { - DoubleMethodCalled = true; - ReceivedDouble = value; - return value; - } - - [SignalR(TestSignalRTags.LongParam)] - public long HandleLong(long value) - { - LongMethodCalled = true; - ReceivedLong = value; - return value; - } - - #endregion - - #region Complex Object Handlers (using shared DTOs) - - [SignalR(TestSignalRTags.TestOrderItemParam)] - public TestOrderItem HandleTestOrderItem(TestOrderItem item) - { - TestOrderItemMethodCalled = true; - ReceivedTestOrderItem = item; - return new TestOrderItem - { - Id = item.Id, - ProductName = $"Processed: {item.ProductName}", - Quantity = item.Quantity * 2, - UnitPrice = item.UnitPrice - }; - } - - [SignalR(TestSignalRTags.TestOrderParam)] - public TestOrder HandleTestOrder(TestOrder order) - { - TestOrderMethodCalled = true; - ReceivedTestOrder = order; - return order; - } - - [SignalR(TestSignalRTags.SharedTagParam)] - public SharedTag HandleSharedTag(SharedTag tag) - { - SharedTagMethodCalled = true; - ReceivedSharedTag = tag; - return tag; - } - - #endregion - - #region Collection Parameter Handlers - - [SignalR(TestSignalRTags.IntArrayParam)] - public int[] HandleIntArray(int[] values) - { - IntArrayMethodCalled = true; - ReceivedIntArray = values; - return values.Select(x => x * 2).ToArray(); - } - - [SignalR(TestSignalRTags.GuidArrayParam)] - public Guid[] HandleGuidArray(Guid[] ids) - { - GuidArrayMethodCalled = true; - ReceivedGuidArray = ids; - return ids; - } - - [SignalR(TestSignalRTags.StringListParam)] - public List HandleStringList(List items) - { - StringListMethodCalled = true; - ReceivedStringList = items; - return items.Select(x => x.ToUpper()).ToList(); - } - - [SignalR(TestSignalRTags.TestOrderItemListParam)] - public List HandleTestOrderItemList(List items) - { - TestOrderItemListMethodCalled = true; - ReceivedTestOrderItemList = items; - return items; - } - - [SignalR(TestSignalRTags.IntListParam)] - public List HandleIntList(List numbers) - { - IntListMethodCalled = true; - ReceivedIntList = numbers; - return numbers.Select(x => x * 2).ToList(); - } - - [SignalR(TestSignalRTags.BoolArrayParam)] - public bool[] HandleBoolArray(bool[] flags) - { - BoolArrayMethodCalled = true; - ReceivedBoolArray = flags; - return flags; - } - - [SignalR(TestSignalRTags.MixedWithArrayParam)] - public string HandleMixedWithArray(bool flag, int[] numbers, string text) - { - MixedWithArrayMethodCalled = true; - ReceivedMixedWithArray = (flag, numbers, text); - return $"{flag}-[{string.Join(",", numbers)}]-{text}"; - } - - [SignalR(TestSignalRTags.NestedListParam)] - public List> HandleNestedList(List> nestedList) - { - NestedListMethodCalled = true; - ReceivedNestedList = nestedList; - return nestedList; - } - - #endregion - - #region Extended Array Parameter Handlers - - [SignalR(TestSignalRTags.LongArrayParam)] - public long[] HandleLongArray(long[] values) - { - LongArrayMethodCalled = true; - ReceivedLongArray = values; - return values; - } - - [SignalR(TestSignalRTags.DecimalArrayParam)] - public decimal[] HandleDecimalArray(decimal[] values) - { - DecimalArrayMethodCalled = true; - ReceivedDecimalArray = values; - return values; - } - - [SignalR(TestSignalRTags.DateTimeArrayParam)] - public DateTime[] HandleDateTimeArray(DateTime[] values) - { - DateTimeArrayMethodCalled = true; - ReceivedDateTimeArray = values; - return values; - } - - [SignalR(TestSignalRTags.EnumArrayParam)] - public TestStatus[] HandleEnumArray(TestStatus[] values) - { - EnumArrayMethodCalled = true; - ReceivedEnumArray = values; - return values; - } - - [SignalR(TestSignalRTags.DoubleArrayParam)] - public double[] HandleDoubleArray(double[] values) - { - DoubleArrayMethodCalled = true; - ReceivedDoubleArray = values; - return values; - } - - [SignalR(TestSignalRTags.SharedTagArrayParam)] - public SharedTag[] HandleSharedTagArray(SharedTag[] tags) - { - SharedTagArrayMethodCalled = true; - ReceivedSharedTagArray = tags; - return tags; - } - - [SignalR(TestSignalRTags.DictionaryParam)] - public Dictionary HandleDictionary(Dictionary dict) - { - DictionaryMethodCalled = true; - ReceivedDictionary = dict; - return dict; - } - - [SignalR(TestSignalRTags.ObjectArrayParam)] - public object[] HandleObjectArray(object[] values) - { - ObjectArrayMethodCalled = true; - ReceivedObjectArray = values; - return values; - } - - #endregion - - #region Mixed Parameter Handlers - - [SignalR(TestSignalRTags.IntAndDtoParam)] - public string HandleIntAndDto(int id, TestOrderItem item) - { - IntAndDtoMethodCalled = true; - ReceivedIntAndDto = (id, item); - return $"{id}-{item?.ProductName}"; - } - - [SignalR(TestSignalRTags.DtoAndListParam)] - public string HandleDtoAndList(TestOrderItem item, List numbers) - { - DtoAndListMethodCalled = true; - ReceivedDtoAndList = (item, numbers); - return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]"; - } - - [SignalR(TestSignalRTags.ThreeComplexParams)] - public string HandleThreeComplexParams(TestOrderItem item, List tags, SharedTag sharedTag) - { - ThreeComplexParamsMethodCalled = true; - ReceivedThreeComplexParams = (item, tags, sharedTag); - return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}"; - } - - [SignalR(TestSignalRTags.FiveParams)] - public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) - { - FiveParamsMethodCalled = true; - ReceivedFiveParams = (a, b, c, d, e); - return $"{a}-{b}-{c}-{d}-{e}"; - } - - #endregion - - public void Reset() - { - // Primitive captures - SingleIntMethodCalled = false; - ReceivedInt = null; - TwoIntMethodCalled = false; - ReceivedTwoInts = null; - BoolMethodCalled = false; - ReceivedBool = null; - StringMethodCalled = false; - ReceivedString = null; - GuidMethodCalled = false; - ReceivedGuid = null; - EnumMethodCalled = false; - ReceivedEnum = null; - NoParamsMethodCalled = false; - MultipleTypesMethodCalled = false; - ReceivedMultipleTypes = null; - DecimalMethodCalled = false; - ReceivedDecimal = null; - DateTimeMethodCalled = false; - ReceivedDateTime = null; - DoubleMethodCalled = false; - ReceivedDouble = null; - LongMethodCalled = false; - ReceivedLong = null; - - // Complex object captures - TestOrderItemMethodCalled = false; - ReceivedTestOrderItem = null; - TestOrderMethodCalled = false; - ReceivedTestOrder = null; - SharedTagMethodCalled = false; - ReceivedSharedTag = null; - - // Collection captures - IntArrayMethodCalled = false; - ReceivedIntArray = null; - GuidArrayMethodCalled = false; - ReceivedGuidArray = null; - StringListMethodCalled = false; - ReceivedStringList = null; - TestOrderItemListMethodCalled = false; - ReceivedTestOrderItemList = null; - IntListMethodCalled = false; - ReceivedIntList = null; - BoolArrayMethodCalled = false; - ReceivedBoolArray = null; - MixedWithArrayMethodCalled = false; - ReceivedMixedWithArray = null; - NestedListMethodCalled = false; - ReceivedNestedList = null; - - // Extended array captures - LongArrayMethodCalled = false; - ReceivedLongArray = null; - DecimalArrayMethodCalled = false; - ReceivedDecimalArray = null; - DateTimeArrayMethodCalled = false; - ReceivedDateTimeArray = null; - EnumArrayMethodCalled = false; - ReceivedEnumArray = null; - DoubleArrayMethodCalled = false; - ReceivedDoubleArray = null; - SharedTagArrayMethodCalled = false; - ReceivedSharedTagArray = null; - DictionaryMethodCalled = false; - ReceivedDictionary = null; - ObjectArrayMethodCalled = false; - ReceivedObjectArray = null; - - // Mixed parameter captures - IntAndDtoMethodCalled = false; - ReceivedIntAndDto = null; - DtoAndListMethodCalled = false; - ReceivedDtoAndList = null; - ThreeComplexParamsMethodCalled = false; - ReceivedThreeComplexParams = null; - FiveParamsMethodCalled = false; - ReceivedFiveParams = null; - } -} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs index 7b27e8f..1b10b26 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs @@ -428,4 +428,44 @@ public class TestSignalRService2 } #endregion + + #region DataSource CRUD Tests + + private readonly List _dataSourceItems = + [ + new() { Id = 1, ProductName = "Product A", Quantity = 10, UnitPrice = 100m }, + new() { Id = 2, ProductName = "Product B", Quantity = 20, UnitPrice = 200m }, + new() { Id = 3, ProductName = "Product C", Quantity = 30, UnitPrice = 300m } + ]; + + [SignalR(TestSignalRTags.DataSourceGetAll)] + public List DataSourceGetAll() => _dataSourceItems.ToList(); + + [SignalR(TestSignalRTags.DataSourceGetItem)] + public TestOrderItem? DataSourceGetItem(int id) => _dataSourceItems.FirstOrDefault(x => x.Id == id); + + [SignalR(TestSignalRTags.DataSourceAdd)] + public TestOrderItem DataSourceAdd(TestOrderItem item) + { + _dataSourceItems.Add(item); + return item; + } + + [SignalR(TestSignalRTags.DataSourceUpdate)] + public TestOrderItem DataSourceUpdate(TestOrderItem item) + { + var index = _dataSourceItems.FindIndex(x => x.Id == item.Id); + if (index >= 0) _dataSourceItems[index] = item; + return item; + } + + [SignalR(TestSignalRTags.DataSourceRemove)] + public TestOrderItem? DataSourceRemove(TestOrderItem item) + { + var existing = _dataSourceItems.FirstOrDefault(x => x.Id == item.Id); + if (existing != null) _dataSourceItems.Remove(existing); + return existing; + } + + #endregion } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs index 05cafb4..0b40054 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs @@ -81,4 +81,11 @@ public abstract class TestSignalRTags : AcSignalRTags public const int PropertyMismatchListParam = 241; public const int PropertyMismatchNestedParam = 242; public const int PropertyMismatchNestedListParam = 243; + + // DataSource CRUD tags + public const int DataSourceGetAll = 300; + public const int DataSourceGetItem = 301; + public const int DataSourceAdd = 302; + public const int DataSourceUpdate = 303; + public const int DataSourceRemove = 304; } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs deleted file mode 100644 index e319d0e..0000000 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.Security.Claims; -using AyCode.Core.Extensions; -using AyCode.Core.Tests.TestModels; -using AyCode.Models.Server.DynamicMethods; -using AyCode.Services.Server.SignalRs; -using AyCode.Services.SignalRs; -using Microsoft.Extensions.Configuration; - -namespace AyCode.Services.Server.Tests.SignalRs; - -/// -/// Testable SignalR hub that overrides infrastructure dependencies. -/// Enables unit testing without SignalR server or mocks. -/// -public class TestableSignalRHub : AcWebSignalRHubBase -{ - #region Captured Data for Assertions - - /// - /// Messages sent via ResponseToCaller or SendMessageToClient - /// - public List SentMessages { get; } = []; - - /// - /// Whether notFoundCallback was invoked - /// - public bool WasNotFoundCallbackInvoked { get; private set; } - - /// - /// The tag name passed to notFoundCallback - /// - public string? NotFoundTagName { get; private set; } - - #endregion - - #region Test Configuration - - /// - /// Simulated connection ID - /// - public string TestConnectionId { get; set; } = "test-connection-id"; - - /// - /// Simulated user identifier - /// - public string? TestUserIdentifier { get; set; } = "test-user-id"; - - /// - /// Simulated connection aborted state - /// - public bool TestIsConnectionAborted { get; set; } = false; - - /// - /// Simulated ClaimsPrincipal (optional) - /// - public ClaimsPrincipal? TestUser { get; set; } - - #endregion - - public TestableSignalRHub() - : base(new ConfigurationBuilder().Build(), new TestLogger()) - { - } - - public TestableSignalRHub(IConfiguration configuration, TestLogger logger) - : base(configuration, logger) - { - } - - #region Public Test Entry Points - - /// - /// Sets the serializer type for testing (JSON or Binary). - /// - public void SetSerializerType(AcSerializerType serializerType) - { - SerializerOptions = serializerType == AcSerializerType.Binary - ? new AcBinarySerializerOptions() - : new AcJsonSerializerOptions(); - } - - /// - /// Register a service with SignalR-attributed methods - /// - public void RegisterService(object service) - { - DynamicMethodCallModels.Add(new AcDynamicMethodCallModel(service)); - } - - /// - /// Invoke ProcessOnReceiveMessage for testing - /// - public Task InvokeProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId = null) - { - return ProcessOnReceiveMessage(messageTag, message, requestId, async tagName => - { - WasNotFoundCallbackInvoked = true; - NotFoundTagName = tagName; - await Task.CompletedTask; - }); - } - - /// - /// Get the logger for assertions - /// - public new TestLogger Logger => base.Logger; - - /// - /// Reset captured state for next test - /// - public void Reset() - { - SentMessages.Clear(); - WasNotFoundCallbackInvoked = false; - NotFoundTagName = null; - Logger.Clear(); - } - - #endregion - - #region Overridden Context Accessors - - protected override string GetConnectionId() => TestConnectionId; - - protected override bool IsConnectionAborted() => TestIsConnectionAborted; - - protected override string? GetUserIdentifier() => TestUserIdentifier; - - protected override ClaimsPrincipal? GetUser() => TestUser; - - #endregion - - #region Overridden Response Methods (capture messages for testing) - - protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) - { - SentMessages.Add(new SentMessage( - MessageTag: messageTag, - Message: message, - RequestId: requestId, - Target: SendTarget.Caller - )); - return Task.CompletedTask; - } - - protected override Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) - { - SentMessages.Add(new SentMessage( - MessageTag: messageTag, - Message: message, - RequestId: requestId, - Target: SendTarget.Client - )); - return Task.CompletedTask; - } - - protected override Task SendMessageToOthers(int messageTag, object? content) - { - SentMessages.Add(new SentMessage( - MessageTag: messageTag, - Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), - RequestId: null, - Target: SendTarget.Others - )); - return Task.CompletedTask; - } - - protected override Task SendMessageToAll(int messageTag, object? content) - { - SentMessages.Add(new SentMessage( - MessageTag: messageTag, - Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), - RequestId: null, - Target: SendTarget.All - )); - return Task.CompletedTask; - } - - protected override Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId) - { - SentMessages.Add(new SentMessage( - MessageTag: messageTag, - Message: message, - RequestId: requestId, - Target: SendTarget.User, - TargetId: userId - )); - return Task.CompletedTask; - } - - #endregion -} - -/// -/// Captured sent message for assertions -/// -public record SentMessage( - int MessageTag, - ISignalRMessage Message, - int? RequestId, - SendTarget Target, - string? TargetId = null) -{ - /// - /// Get the response as SignalResponseJsonMessage for inspection - /// - public SignalResponseJsonMessage? AsJsonResponse => Message as SignalResponseJsonMessage; -} - -/// -/// Target of the sent message -/// -public enum SendTarget -{ - Caller, - Client, - Others, - All, - User, - Group -} diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs index e7e03f5..ec0f642 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs @@ -275,7 +275,8 @@ namespace AyCode.Services.Server.SignalRs } /// - /// GetAllMessageTag - Async callback version with optimized JSON handling + /// GetAllMessageTag - Async callback version with optimized direct populate. + /// Uses SignalResponseDataMessage to avoid double deserialization. /// public Task LoadDataSourceAsync(bool clearChangeTracking = true) { @@ -283,44 +284,80 @@ namespace AyCode.Services.Server.SignalRs throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); BeginSync(); - return SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, async result => - { - try + // Request SignalResponseDataMessage directly to avoid deserializing ResponseData + return SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, GetContextParams()) + .ContinueWith(async task => { - if (result.Status != SignalResponseStatus.Success || string.IsNullOrEmpty(result.ResponseDataJson)) - throw new NullReferenceException($"LoadDataSourceAsync; Status: {result.Status}"); + try + { + var response = task.Result; + if (response?.Status != SignalResponseStatus.Success || response.ResponseDataBin == null) + throw new NullReferenceException($"LoadDataSourceAsync; Status: {response?.Status}"); - await LoadDataSourceFromJson(result.ResponseDataJson, false, false, clearChangeTracking); - } - finally - { - EndSync(); - } - }, GetContextParams()); + await LoadDataSourceFromResponseData(response.ResponseDataBin, response.DataSerializerType, + false, false, clearChangeTracking); + } + finally + { + EndSync(); + } + }).Unwrap(); } /// - /// Loads data source directly from JSON string, avoiding double deserialization. + /// Loads data source directly from ResponseData byte[], avoiding double deserialization. /// - public async Task LoadDataSourceFromJson(string json, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) + public async Task LoadDataSourceFromResponseData(byte[] responseData, AcSerializerType serializerType, + bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) { await _asyncLock.WaitAsync(); try { if (!setSourceToWorkingReferenceList) { - if (InnerList is IAcObservableCollection observable) + // Direct populate into existing InnerList + if (serializerType == AcSerializerType.Binary) { - observable.PopulateFromJson(json); + if (InnerList is IAcObservableCollection observable) + { + observable.BeginUpdate(); + try + { + responseData.BinaryToMerge(InnerList); + } + finally + { + observable.EndUpdate(); + } + } + else + { + responseData.BinaryTo(InnerList); + } } else { - json.JsonTo(InnerList); + // JSON mode + var json = System.Text.Encoding.UTF8.GetString(responseData); + if (InnerList is IAcObservableCollection observable) + { + observable.PopulateFromJson(json); + } + else + { + json.JsonTo(InnerList); + } } } else { - var fromSource = json.JsonTo(); + // Deserialize to new list and set as reference + TIList? fromSource; + if (serializerType == AcSerializerType.Binary) + fromSource = responseData.BinaryTo(); + else + fromSource = System.Text.Encoding.UTF8.GetString(responseData).JsonTo(); + if (fromSource != null) { ClearUnsafe(clearChangeTracking); @@ -891,7 +928,7 @@ namespace AyCode.Services.Server.SignalRs private Task SaveTrackingItemUnsafe(TrackingItem trackingItem) => SaveItemUnsafe(trackingItem.CurrentValue, trackingItem.TrackingState); - private Task SaveTrackingItemUnsafeAsync(TrackingItem trackingItem) + private async Task SaveTrackingItemUnsafeAsync(TrackingItem trackingItem) => SaveItemUnsafeAsync(trackingItem.CurrentValue, trackingItem.TrackingState); private Task SaveItemUnsafe(TDataItem item, TrackingState trackingState) @@ -900,19 +937,22 @@ namespace AyCode.Services.Server.SignalRs if (messageTag == AcSignalRTags.None) throw new ArgumentException($"SaveItemUnsafe; messageTag == SignalRTags.None"); - return SignalRClient.PostDataAsync(messageTag, item).ContinueWith(x => + return SignalRClient.PostDataAsync(messageTag, item).ContinueWith(task => { - if (x.Result == null) + if (task.Result == null) { if (TryRollbackItem(item.Id, out _)) return item; throw new NullReferenceException($"SaveItemUnsafe; result == null"); } - ProcessSavedResponseItem(x.Result, trackingState, item.Id); - return x.Result; - }); + ProcessSavedResponseItem(task.Result, trackingState, item.Id); + return task.Result; + }, TaskScheduler.Default); } + /// + /// Saves item in background (fire-and-forget friendly). Does not block UI. + /// private Task SaveItemUnsafeAsync(TDataItem item, TrackingState trackingState) { var messageTag = SignalRCrudTags.GetMessageTagByTrackingState(trackingState); @@ -920,14 +960,14 @@ namespace AyCode.Services.Server.SignalRs return SignalRClient.PostDataAsync(messageTag, item, response => { - //response.ResponseDataJson - if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) + if (response.Status != SignalResponseStatus.Success || response.ResponseDataBin == null) { - if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; + if (TryRollbackItem(item.Id, out _)) return; throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}"); } - return ProcessSavedResponseItem(response.ResponseData, trackingState, item.Id); + var resultItem = response.GetResponseData(); + ProcessSavedResponseItem(resultItem, trackingState, item.Id); }); } diff --git a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs index 45c9ad6..7206f38 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs @@ -1,57 +1,37 @@ using AyCode.Core.Extensions; using AyCode.Core.Helpers; using AyCode.Core.Loggers; -using AyCode.Services.Loggers; using AyCode.Services.SignalRs; -using MessagePack.Resolvers; using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; namespace AyCode.Services.Server.SignalRs; -public abstract class AcSignalRSendToClientService(IHubContext signalRHub, IAcLoggerBase logger) //: IAcSignalRHubServer +public abstract class AcSignalRSendToClientService(IHubContext signalRHub, IAcLoggerBase logger) where TSignalRHub: Hub, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : IAcLoggerBase { protected IAcLoggerBase Logger => logger; protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, object? content) { - var jsonContent = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content); - await SendMessageToClient(sendTo, messageTag, jsonContent, null); + var response = new SignalResponseDataMessage(messageTag, SignalResponseStatus.Success, content, AcJsonSerializerOptions.Default); + var responseBytes = response.ToBinary(); + + Logger.Info($"[{responseBytes.Length / 1024}kb] Server sending to client; {ConstHelper.NameByValue(messageTag)}"); + await sendTo.OnReceiveMessage(messageTag, responseBytes, null); } - protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) - { - var sendingDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options); + public virtual Task SendMessageToAllClients(int messageTag, object? content) + => SendMessageToClient(signalRHub.Clients.All, messageTag, content); - Logger.Info($"[{(sendingDataMessagePack.Length/1024)}kb] Server sending dataMessagePack to client; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(messageTag)}"); - //Logger.Info($"[{(responseDataMessagePack.Length/1024)}kb] Server sending dataMessagePack to client; {nameof(requestId)}: {requestId}; ConnectionId: {signalRHub.ConnectionId}; {ConstHelper.NameByValue(messageTag)}"); + public virtual Task SendMessageToConnection(string connectionId, int messageTag, object? content) + => SendMessageToClient(signalRHub.Clients.Client(connectionId), messageTag, content); - await sendTo.OnReceiveMessage(messageTag, sendingDataMessagePack, requestId); - } + public virtual Task SendMessageToConnections(IEnumerable connectionIds, int messageTag, object? content) + => SendMessageToClient(signalRHub.Clients.Clients(connectionIds), messageTag, content); - public virtual async Task SendMessageToAllClients(int messageTag, object? content) - { - await SendMessageToClient(signalRHub.Clients.All, messageTag, content); - } + public virtual Task SendMessageToUser(string user, int messageTag, object? content) + => SendMessageToClient(signalRHub.Clients.User(user), messageTag, content); - public virtual async Task SendMessageToConnection(string connectionId, int messageTag, object? content) - { - await SendMessageToClient(signalRHub.Clients.Client(connectionId), messageTag, content); - } - - public virtual async Task SendMessageToConnections(IEnumerable connectionIds, int messageTag, object? content) - { - await SendMessageToClient(signalRHub.Clients.Clients(connectionIds), messageTag, content); - } - - public virtual async Task SendMessageToUser(string user, int messageTag, object? content) - { - await SendMessageToClient(signalRHub.Clients.User(user), messageTag, content); - } - - public virtual async Task SendMessageToUsers(IEnumerable users, int messageTag, object? content) - { - await SendMessageToClient(signalRHub.Clients.Users(users), messageTag, content); - } + public virtual Task SendMessageToUsers(IEnumerable users, int messageTag, object? content) + => SendMessageToClient(signalRHub.Clients.Users(users), messageTag, content); } \ No newline at end of file diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index ea83224..c7abc99 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Buffers; +using System.Security.Claims; using AyCode.Core; using AyCode.Core.Extensions; using AyCode.Core.Helpers; @@ -96,27 +97,22 @@ public abstract class AcWebSignalRHubBase(IConfiguration } /// - /// Creates a response message using the configured serializer (JSON or Binary). + /// Creates a response message using the configured serializer. + /// Always creates SignalResponseDataMessage which includes the SerializerType. /// protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData) { - if (SerializerOptions.SerializerType == AcSerializerType.Binary) - { - return new SignalResponseBinaryMessage(messageTag, status, responseData, (AcBinarySerializerOptions)SerializerOptions); - } - - return new SignalResponseJsonMessage(messageTag, status, responseData); + return new SignalResponseDataMessage(messageTag, status, responseData, SerializerOptions); } /// /// Gets the size of the response data for logging purposes. /// - private int GetResponseSize(ISignalRMessage responseMessage) + private static int GetResponseSize(ISignalRMessage responseMessage) { return responseMessage switch { - SignalResponseJsonMessage jsonMsg => System.Text.Encoding.Unicode.GetByteCount(jsonMsg.ResponseData ?? ""), - SignalResponseBinaryMessage binaryMsg => binaryMsg.ResponseData?.Length ?? 0, + SignalResponseDataMessage dataMsg => dataMsg.ResponseDataBin?.Length ?? 0, _ => 0 }; } @@ -246,14 +242,20 @@ public abstract class AcWebSignalRHubBase(IConfiguration protected virtual Task SendMessageToAll(int messageTag, object? content) => SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); + /// + /// Sends message to client. + /// Both Binary and JSON modes use AcBinarySerializer directly (no MessagePack wrapper). + /// protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) { - var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options); + // Both modes use AcBinarySerializer - unified serialization + var responseBytes = message.ToBinary(); + var tagName = ConstHelper.NameByValue(messageTag); - Logger.Debug($"[{responseDataMessagePack.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}"); + Logger.Debug($"[{responseBytes.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}"); - await sendTo.OnReceiveMessage(messageTag, responseDataMessagePack, requestId); + await sendTo.OnReceiveMessage(messageTag, responseBytes, requestId); Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); } diff --git a/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs b/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs deleted file mode 100644 index 9a40cee..0000000 --- a/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs +++ /dev/null @@ -1,1158 +0,0 @@ -using AyCode.Core.Extensions; -using AyCode.Core.Helpers; -using AyCode.Core.Tests.TestModels; -using AyCode.Services.SignalRs; -using MessagePack.Resolvers; -using Microsoft.AspNetCore.SignalR.Client; - -namespace AyCode.Services.Tests.SignalRs; - -[TestClass] -public class AcSignalRClientBaseTests -{ - private TestableSignalRClient _client = null!; - private TestLogger _logger = null!; - - [TestInitialize] - public void Setup() - { - _logger = new TestLogger(); - _client = new TestableSignalRClient(_logger); - _client.TransportSendTimeout = 100; // Short timeout for tests - } - - #region Connection State Tests - - [TestMethod] - public async Task StartConnection_WhenDisconnected_ConnectsSuccessfully() - { - _client.SetConnectionState(HubConnectionState.Disconnected); - - await _client.StartConnection(); - - Assert.IsTrue(_client.GetPendingRequests().IsEmpty); - } - - [TestMethod] - public async Task StopConnection_ClearsState() - { - _client.RegisterPendingRequest(1, new SignalRRequestModel()); - - await _client.StopConnection(); - - // Connection should be stopped (no exception) - } - - #endregion - - #region SendMessageToServerAsync Tests - - [TestMethod] - public async Task SendMessageToServerAsync_SendsMessage() - { - await _client.SendMessageToServerAsync(TestClientTags.Ping); - - Assert.AreEqual(1, _client.SentMessages.Count); - Assert.AreEqual(TestClientTags.Ping, _client.LastSentMessage?.MessageTag); - } - - [TestMethod] - public async Task SendMessageToServerAsync_WithMessage_SerializesCorrectly() - { - var idMessage = new IdMessage(42); - var message = new SignalPostJsonDataMessage(idMessage); - - await _client.SendMessageToServerAsync(TestClientTags.GetById, message, 1); - - Assert.AreEqual(1, _client.SentMessages.Count); - var sent = _client.LastSentMessage; - Assert.IsNotNull(sent?.MessageBytes); - Assert.AreEqual(1, sent.RequestId); - } - - [TestMethod] - public async Task SendMessageToServerAsync_WhenDisconnected_LogsError() - { - _client.SetConnectionState(HubConnectionState.Disconnected); - - await _client.SendMessageToServerAsync(TestClientTags.Ping, null, 1); - - // Should attempt to connect first, then send - Assert.AreEqual(1, _client.SentMessages.Count); - } - - #endregion - - #region GetByIdAsync Tests - - [TestMethod] - public async Task GetByIdAsync_SendsIdMessageWithSingleId() - { - _client.SetNextRequestId(100); - - // Start the async operation but don't await the result (it will timeout) - _ = Task.Run(() => _client.GetByIdAsync(TestClientTags.GetById, 42)); - - // Give it time to send - await Task.Delay(50); - - Assert.IsTrue(_client.SentMessages.Count >= 1); - var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.GetById); - Assert.IsNotNull(sent); - Assert.AreEqual(100, sent.RequestId); - - var idMessage = sent.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); // Direct JSON format - } - - [TestMethod] - public async Task GetByIdAsync_WithGuid_SendsCorrectly() - { - var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc"); - _client.SetNextRequestId(101); - - _ = Task.Run(() => _client.GetByIdAsync(TestClientTags.GetById, guid)); - await Task.Delay(50); - - var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.GetById); - Assert.IsNotNull(sent); - var idMessage = sent.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.IsTrue(idMessage.Ids[0].Contains("12345678")); - } - - [TestMethod] - public async Task GetByIdAsync_WithMultipleIds_SendsAllIds() - { - _client.SetNextRequestId(102); - - var task = _client.GetByIdAsync>(TestClientTags.GetById, new object[] { 1, 2, 3 }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(3, idMessage.Ids.Count); - } - - [TestMethod] - public async Task GetByIdAsync_WithCallback_InvokesCallback() - { - _client.SetNextRequestId(103); - var callbackInvoked = false; - TestOrderItem? receivedData = null; - - var task = _client.GetByIdAsync(TestClientTags.GetById, async response => - { - callbackInvoked = true; - receivedData = response.ResponseData; - await Task.CompletedTask; - }, 42); - - await Task.Delay(10); - - // Simulate server response - var responseItem = new TestOrderItem { Id = 42, ProductName = "Test Product" }; - await _client.SimulateSuccessResponse(103, TestClientTags.GetById, responseItem); - - await Task.Delay(10); - - Assert.IsTrue(callbackInvoked); - Assert.IsNotNull(receivedData); - Assert.AreEqual(42, receivedData.Id); - Assert.AreEqual("Test Product", receivedData.ProductName); - } - - #endregion - - #region GetAllAsync Tests - - [TestMethod] - public async Task GetAllAsync_SendsMessageWithoutParams() - { - _client.SetNextRequestId(200); - - var task = _client.GetAllAsync>(TestClientTags.GetAll); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - Assert.AreEqual(1, _client.SentMessages.Count); - Assert.AreEqual(TestClientTags.GetAll, _client.LastSentMessage?.MessageTag); - } - - [TestMethod] - public async Task GetAllAsync_WithContextParams_SendsParams() - { - _client.SetNextRequestId(201); - - var task = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true, "filter" }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(2, idMessage.Ids.Count); - } - - [TestMethod] - public async Task GetAllAsync_WithEmptyParams_SendsWithoutMessage() - { - _client.SetNextRequestId(202); - - var task = _client.GetAllAsync>(TestClientTags.GetAll, Array.Empty()); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - // Empty params should not create IdMessage - Assert.IsNull(_client.LastSentMessage?.MessageBytes); - } - - #endregion - - #region PostDataAsync Tests - - [TestMethod] - public async Task PostDataAsync_SendsComplexObject() - { - _client.SetNextRequestId(300); - var orderItem = new TestOrderItem { Id = 1, ProductName = "New Product", Quantity = 5 }; - - _ = Task.Run(() => _client.PostDataAsync(TestClientTags.Create, orderItem)); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - Assert.IsTrue(_client.SentMessages.Count >= 1, $"Expected at least 1 message, got {_client.SentMessages.Count}"); - var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.Create); - Assert.IsNotNull(sent); - Assert.IsNotNull(sent.MessageBytes); - - var postData = sent.AsPostData(); - Assert.IsNotNull(postData); - Assert.AreEqual("New Product", postData.ProductName); - } - - [TestMethod] - public async Task PostDataAsync_WithNestedObject_SerializesCorrectly() - { - _client.SetNextRequestId(301); - var order = TestDataFactory.CreateOrder(itemCount: 2); - - _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var postData = _client.LastSentMessage?.AsPostData(); - Assert.IsNotNull(postData); - Assert.AreEqual(2, postData.Items.Count); - } - - #endregion - - #region OnReceiveMessage Tests - - [TestMethod] - public async Task OnReceiveMessage_WithPendingRequest_SetsResponse() - { - var requestId = 400; - // Register with null callback - response will be stored in ResponseByRequestId - _client.RegisterPendingRequest(requestId, new SignalRRequestModel()); - - await _client.SimulateSuccessResponse(requestId, TestClientTags.Echo, "Hello"); - - // After receiving response, the request should have ResponseByRequestId set - // The pending request gets removed after callback handling, but for null callback - // it stays with the response set - var pending = _client.GetPendingRequests(); - if (pending.TryGetValue(requestId, out var model)) - { - Assert.IsNotNull(model.ResponseByRequestId); - } - // If not in pending, it means it was handled (which is also valid) - } - - [TestMethod] - public async Task OnReceiveMessage_WithCallback_InvokesCallback() - { - var requestId = 401; - var callbackInvoked = false; - string? receivedData = null; - - _client.RegisterPendingRequest(requestId, new SignalRRequestModel(new Action>(response => - { - callbackInvoked = true; - receivedData = response.ResponseData; - }))); - - await _client.SimulateSuccessResponse(requestId, TestClientTags.Echo, "Hello World"); - - Assert.IsTrue(callbackInvoked); - Assert.IsNotNull(receivedData); - } - - [TestMethod] - public async Task OnReceiveMessage_WithoutPendingRequest_CallsMessageReceived() - { - var response = new SignalResponseJsonMessage(TestClientTags.GetStatus, SignalResponseStatus.Success, "OK"); - var bytes = response.ToMessagePack(ContractlessStandardResolver.Options); - - await _client.InvokeOnReceiveMessage(TestClientTags.GetStatus, bytes, null); - - Assert.AreEqual(1, _client.ReceivedMessages.Count); - Assert.AreEqual(TestClientTags.GetStatus, _client.ReceivedMessages[0].MessageTag); - } - - [TestMethod] - public async Task OnReceiveMessage_WithEmptyBytes_LogsWarning() - { - await _client.InvokeOnReceiveMessage(TestClientTags.Echo, Array.Empty(), 999); - - Assert.IsTrue(_logger.HasWarningLogs); - } - - #endregion - - #region Error Handling Tests - - [TestMethod] - public async Task GetByIdAsync_WithErrorResponse_ReturnsDefault() - { - _client.SetNextRequestId(500); - - var task = Task.Run(async () => - { - try - { - return await _client.GetByIdAsync(TestClientTags.NotFound, 999); - } - catch - { - return null; - } - }); - - await Task.Delay(10); - await _client.SimulateErrorResponse(500, TestClientTags.NotFound); - - var result = await task; - Assert.IsNull(result); - } - - [TestMethod] - public async Task SendMessageToServerAsync_WithZeroTag_LogsError() - { - await _client.SendMessageToServerAsync(0, response => Task.CompletedTask); - - Assert.IsTrue(_logger.HasErrorLogs); - } - - #endregion - - #region Complex Scenario Tests - - [TestMethod] - public async Task MultipleParallelRequests_HandleCorrectly() - { - _client.SetNextRequestId(600); - - // Start multiple requests - var task1 = Task.Run(async () => - { - var t = _client.GetByIdAsync(TestClientTags.GetById, 1); - await Task.Delay(5); - return 600; - }); - - var task2 = Task.Run(async () => - { - var t = _client.GetByIdAsync(TestClientTags.GetById, 2); - await Task.Delay(5); - return 601; - }); - - await Task.WhenAll(task1, task2); - - // Both requests should have been sent - Assert.AreEqual(2, _client.SentMessages.Count); - } - - [TestMethod] - public async Task ConcurrentResponseHandling_ResolvesCorrectRequests() - { - _client.SetNextRequestId(700); - - // Register two pending requests with callbacks - var results = new Dictionary(); - - _client.RegisterPendingRequest(700, new SignalRRequestModel(new Action>(response => - { - results[700] = response.ResponseData; - }))); - - _client.RegisterPendingRequest(701, new SignalRRequestModel(new Action>(response => - { - results[701] = response.ResponseData; - }))); - - // Simulate responses in reverse order - await _client.SimulateSuccessResponse(701, TestClientTags.Echo, "Response 701"); - await _client.SimulateSuccessResponse(700, TestClientTags.Echo, "Response 700"); - - // Each request should get its correct response - Assert.AreEqual(2, results.Count); - } - - #endregion - - #region IdMessage Single Primitive Parameter Tests - - [TestMethod] - public void IdMessage_WithInt_SerializesAsNumber() - { - var idMessage = new IdMessage(42); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithLong_SerializesAsNumber() - { - var idMessage = new IdMessage(9223372036854775807L); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("9223372036854775807", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithBoolTrue_SerializesCorrectly() - { - var idMessage = new IdMessage(true); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("true", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithBoolFalse_SerializesCorrectly() - { - var idMessage = new IdMessage(false); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("false", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithString_SerializesAsQuotedString() - { - var idMessage = new IdMessage("hello"); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("\"hello\"", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithDouble_SerializesAsNumber() - { - var idMessage = new IdMessage(3.14159); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].StartsWith("3.14")); - } - - [TestMethod] - public void IdMessage_WithDecimal_SerializesAsNumber() - { - var idMessage = new IdMessage(99.99m); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("99.99", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithGuid_SerializesAsQuotedString() - { - var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc"); - var idMessage = new IdMessage(guid); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("12345678")); - Assert.IsTrue(idMessage.Ids[0].StartsWith("\"") && idMessage.Ids[0].EndsWith("\"")); - } - - [TestMethod] - public void IdMessage_WithDateTime_SerializesAsQuotedString() - { - var date = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc); - var idMessage = new IdMessage(date); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("2024")); - } - - [TestMethod] - public void IdMessage_WithEnum_SerializesAsNumber() - { - var idMessage = new IdMessage(TestStatus.Processing); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("2", idMessage.Ids[0]); // Processing = 2 - } - - #endregion - - #region IdMessage Multiple Primitive Parameters Tests - - [TestMethod] - public void IdMessage_WithTwoInts_SerializesCorrectly() - { - var idMessage = new IdMessage(new object[] { 10, 20 }); - - Assert.AreEqual(2, idMessage.Ids.Count); - Assert.AreEqual("10", idMessage.Ids[0]); - Assert.AreEqual("20", idMessage.Ids[1]); - } - - [TestMethod] - public void IdMessage_WithThreeMixedTypes_SerializesCorrectly() - { - var idMessage = new IdMessage(new object[] { true, "test", 123 }); - - Assert.AreEqual(3, idMessage.Ids.Count); - Assert.AreEqual("true", idMessage.Ids[0]); - Assert.AreEqual("\"test\"", idMessage.Ids[1]); - Assert.AreEqual("123", idMessage.Ids[2]); - } - - [TestMethod] - public void IdMessage_WithFiveParams_SerializesCorrectly() - { - var testGuid = Guid.NewGuid(); - var idMessage = new IdMessage(new object[] { 42, "hello", true, testGuid, 99.99m }); - - Assert.AreEqual(5, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - Assert.AreEqual("\"hello\"", idMessage.Ids[1]); - Assert.AreEqual("true", idMessage.Ids[2]); - Assert.IsTrue(idMessage.Ids[3].Contains(testGuid.ToString())); - Assert.AreEqual("99.99", idMessage.Ids[4]); - } - - [TestMethod] - public void IdMessage_WithIntBoolStringGuidDecimal_AllTypesSerializeCorrectly() - { - var guid = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); - var idMessage = new IdMessage(new object[] { - 42, // int - true, // bool - "text", // string - guid, // Guid - 123.45m // decimal - }); - - Assert.AreEqual(5, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - Assert.AreEqual("true", idMessage.Ids[1]); - Assert.AreEqual("\"text\"", idMessage.Ids[2]); - Assert.IsTrue(idMessage.Ids[3].Contains("aaaaaaaa")); - Assert.AreEqual("123.45", idMessage.Ids[4]); - } - - #endregion - - #region IdMessage No Parameters Tests - - [TestMethod] - public void IdMessage_WithEmptyArray_HasNoIds() - { - var idMessage = new IdMessage(Array.Empty()); - - Assert.AreEqual(0, idMessage.Ids.Count); - } - - [TestMethod] - public async Task GetAllAsync_WithNullParams_SendsWithoutMessage() - { - _client.SetNextRequestId(203); - - object[]? nullParams = null; - var task = _client.GetAllAsync>(TestClientTags.GetAll, nullParams); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - // Null params should not create IdMessage - Assert.IsNull(_client.LastSentMessage?.MessageBytes); - } - - [TestMethod] - public async Task SendMessageToServerAsync_NoParams_SendsWithoutBytes() - { - await _client.SendMessageToServerAsync(TestClientTags.Ping, null, 1); - - Assert.AreEqual(1, _client.SentMessages.Count); - Assert.IsNull(_client.LastSentMessage?.MessageBytes); - } - - #endregion - - #region IdMessage Complex Object Tests - - [TestMethod] - public void IdMessage_WithComplexObject_SerializesAsJson() - { - var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 5 }; - var idMessage = new IdMessage(item); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("\"ProductName\"")); - Assert.IsTrue(idMessage.Ids[0].Contains("\"Test\"")); - } - - [TestMethod] - public void IdMessage_WithNestedObject_SerializesCorrectly() - { - var order = new TestOrder - { - Id = 100, - OrderNumber = "ORD-001", - Items = new List - { - new() { Id = 1, ProductName = "Item A" }, - new() { Id = 2, ProductName = "Item B" } - } - }; - var idMessage = new IdMessage(order); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("ORD-001")); - Assert.IsTrue(idMessage.Ids[0].Contains("Item A")); - Assert.IsTrue(idMessage.Ids[0].Contains("Item B")); - } - - [TestMethod] - public void IdMessage_WithSharedTag_IIdType_SerializesCorrectly() - { - var tag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" }; - var idMessage = new IdMessage(tag); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("Important")); - Assert.IsTrue(idMessage.Ids[0].Contains("#FF0000")); - } - - #endregion - - #region IdMessage Array/Collection Tests - - [TestMethod] - public void IdMessage_WithIntArray_SerializesAsJsonArray() - { - var values = new[] { 1, 2, 3, 4, 5 }; - var idMessage = new IdMessage(values); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("[")); - Assert.IsTrue(idMessage.Ids[0].Contains("1")); - Assert.IsTrue(idMessage.Ids[0].Contains("5")); - } - - [TestMethod] - public void IdMessage_WithStringList_SerializesAsJsonArray() - { - var items = new List { "apple", "banana", "cherry" }; - var idMessage = new IdMessage(items); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("apple")); - Assert.IsTrue(idMessage.Ids[0].Contains("banana")); - } - - [TestMethod] - public void IdMessage_WithGuidArray_SerializesAsJsonArray() - { - // Guid[] is treated as a single object and serialized as JSON array - // Use new object[] { guids } to pass it as single parameter - var guids = new[] { Guid.NewGuid(), Guid.NewGuid() }; - var idMessage = new IdMessage(new object[] { guids }); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].StartsWith("[")); - Assert.IsTrue(idMessage.Ids[0].EndsWith("]")); - } - - [TestMethod] - public void IdMessage_WithGuidArrayAsMultipleParams_EnumeratesEachGuid() - { - // Using IEnumerable constructor - each guid becomes separate Id - var guids = new[] { Guid.NewGuid(), Guid.NewGuid() }; - var idMessage = new IdMessage((IEnumerable)guids); - - Assert.AreEqual(2, idMessage.Ids.Count); - } - - [TestMethod] - public void IdMessage_WithEmptyArray_SerializesAsEmptyJsonArray() - { - var empty = Array.Empty(); - var idMessage = new IdMessage(empty); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("[]", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithTestOrderItemList_SerializesAsJsonArray() - { - var items = new List - { - new() { Id = 1, ProductName = "First" }, - new() { Id = 2, ProductName = "Second" } - }; - var idMessage = new IdMessage(items); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("First")); - Assert.IsTrue(idMessage.Ids[0].Contains("Second")); - } - - #endregion - - #region IdMessage Mixed Parameters (Primitive + Complex + Collection) Tests - - [TestMethod] - public void IdMessage_WithIntAndDto_SerializesCorrectly() - { - var item = new TestOrderItem { Id = 10, ProductName = "Test" }; - var idMessage = new IdMessage(new object[] { 42, item }); - - Assert.AreEqual(2, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - Assert.IsTrue(idMessage.Ids[1].Contains("Test")); - } - - [TestMethod] - public void IdMessage_WithDtoAndList_SerializesCorrectly() - { - var item = new TestOrderItem { Id = 1, ProductName = "Product" }; - var numbers = new List { 1, 2, 3 }; - var idMessage = new IdMessage(new object[] { item, numbers }); - - Assert.AreEqual(2, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("Product")); - Assert.IsTrue(idMessage.Ids[1].Contains("[")); - } - - [TestMethod] - public void IdMessage_WithBoolArrayString_MixedWithArray_SerializesCorrectly() - { - var numbers = new[] { 1, 2, 3 }; - var idMessage = new IdMessage(new object[] { true, numbers, "hello" }); - - Assert.AreEqual(3, idMessage.Ids.Count); - Assert.AreEqual("true", idMessage.Ids[0]); - Assert.IsTrue(idMessage.Ids[1].Contains("[1,2,3]") || idMessage.Ids[1].Contains("[1, 2, 3]")); - Assert.AreEqual("\"hello\"", idMessage.Ids[2]); - } - - [TestMethod] - public void IdMessage_WithThreeComplexParams_SerializesCorrectly() - { - var item = new TestOrderItem { Id = 1, ProductName = "Product" }; - var tags = new List { "tag1", "tag2" }; - var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; - var idMessage = new IdMessage(new object[] { item, tags, sharedTag }); - - Assert.AreEqual(3, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("Product")); - Assert.IsTrue(idMessage.Ids[1].Contains("tag1")); - Assert.IsTrue(idMessage.Ids[2].Contains("Shared")); - } - - [TestMethod] - public void IdMessage_WithFiveMixedParams_AllTypesSerialize() - { - var guid = Guid.NewGuid(); - var item = new TestOrderItem { Id = 5, ProductName = "Mixed" }; - var numbers = new[] { 10, 20, 30 }; - - var idMessage = new IdMessage(new object[] { - 42, // int - guid, // Guid - item, // complex object - numbers, // array - "final" // string - }); - - Assert.AreEqual(5, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - Assert.IsTrue(idMessage.Ids[1].Contains(guid.ToString())); - Assert.IsTrue(idMessage.Ids[2].Contains("Mixed")); - Assert.IsTrue(idMessage.Ids[3].Contains("[")); - Assert.AreEqual("\"final\"", idMessage.Ids[4]); - } - - #endregion - - #region GetAllAsync Parameter Type Tests - - [TestMethod] - public async Task GetAllAsync_WithSingleBoolParam_SerializesCorrectly() - { - _client.SetNextRequestId(210); - - _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("true", idMessage.Ids[0]); - } - - [TestMethod] - public async Task GetAllAsync_WithIntAndString_SerializesCorrectly() - { - _client.SetNextRequestId(211); - - _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { 100, "filter" }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(2, idMessage.Ids.Count); - Assert.AreEqual("100", idMessage.Ids[0]); - Assert.AreEqual("\"filter\"", idMessage.Ids[1]); - } - - [TestMethod] - public async Task GetAllAsync_WithGuidParam_SerializesCorrectly() - { - _client.SetNextRequestId(212); - var guid = Guid.NewGuid(); - - _ = _client.GetAllAsync(TestClientTags.GetAll, new object[] { guid }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains(guid.ToString())); - } - - [TestMethod] - public async Task GetAllAsync_WithComplexObjectParam_SerializesCorrectly() - { - _client.SetNextRequestId(213); - var filter = new TestOrderItem { Id = 0, ProductName = "SearchFilter" }; - - _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { filter }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("SearchFilter")); - } - - [TestMethod] - public async Task GetAllAsync_WithArrayParam_SerializesCorrectly() - { - _client.SetNextRequestId(214); - var ids = new[] { 1, 2, 3, 4, 5 }; - - _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { ids }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("[")); - } - - [TestMethod] - public async Task GetAllAsync_WithThreeMixedParams_SerializesCorrectly() - { - _client.SetNextRequestId(215); - var tag = new SharedTag { Id = 1, Name = "Filter" }; - - _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true, 50, tag }); - await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); - - var idMessage = _client.LastSentMessage?.AsIdMessage(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(3, idMessage.Ids.Count); - Assert.AreEqual("true", idMessage.Ids[0]); - Assert.AreEqual("50", idMessage.Ids[1]); - Assert.IsTrue(idMessage.Ids[2].Contains("Filter")); - } - - #endregion - - #region PostDataAsync Parameter Type Tests - - [TestMethod] - public async Task PostDataAsync_WithSimpleDto_SerializesCorrectly() - { - _client.SetNextRequestId(320); - var item = new TestOrderItem { Id = 1, ProductName = "Simple", Quantity = 10, UnitPrice = 25.50m }; - - _ = Task.Run(() => _client.PostDataAsync(TestClientTags.Create, item)); - await Task.Delay(50); - - var postData = _client.LastSentMessage?.AsPostData(); - Assert.IsNotNull(postData); - Assert.AreEqual("Simple", postData.ProductName); - Assert.AreEqual(10, postData.Quantity); - Assert.AreEqual(25.50m, postData.UnitPrice); - } - - [TestMethod] - public async Task PostDataAsync_WithDtoContainingList_SerializesCorrectly() - { - _client.SetNextRequestId(321); - var order = new TestOrder - { - Id = 1, - OrderNumber = "ORD-TEST", - Items = new List - { - new() { Id = 1, ProductName = "A", Quantity = 1 }, - new() { Id = 2, ProductName = "B", Quantity = 2 }, - new() { Id = 3, ProductName = "C", Quantity = 3 } - } - }; - - _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); - await Task.Delay(50); - - var postData = _client.LastSentMessage?.AsPostData(); - Assert.IsNotNull(postData); - Assert.AreEqual("ORD-TEST", postData.OrderNumber); - Assert.AreEqual(3, postData.Items.Count); - } - - [TestMethod] - public async Task PostDataAsync_WithDtoContainingSharedRefs_SerializesCorrectly() - { - _client.SetNextRequestId(322); - var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; - var order = new TestOrder - { - Id = 1, - OrderNumber = "ORD-SHARED", - PrimaryTag = sharedTag, - SecondaryTag = sharedTag - }; - - _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); - await Task.Delay(50); - - var postData = _client.LastSentMessage?.AsPostData(); - Assert.IsNotNull(postData); - Assert.IsNotNull(postData.PrimaryTag); - Assert.AreEqual("Shared", postData.PrimaryTag.Name); - } - - [TestMethod] - public async Task PostDataAsync_WithList_SerializesCorrectly() - { - _client.SetNextRequestId(323); - var items = new List - { - new() { Id = 1, ProductName = "First" }, - new() { Id = 2, ProductName = "Second" } - }; - - _ = Task.Run(() => _client.PostDataAsync, List>(TestClientTags.Create, items)); - await Task.Delay(50); - - var postData = _client.LastSentMessage?.AsPostData>(); - Assert.IsNotNull(postData); - Assert.AreEqual(2, postData.Count); - } - - #endregion - - #region Edge Cases Tests - - [TestMethod] - public void IdMessage_WithNullInArray_HandlesGracefully() - { - // This tests how null values are handled - they should serialize as "null" - var idMessage = new IdMessage(new object[] { 42, null!, "text" }); - - Assert.AreEqual(3, idMessage.Ids.Count); - Assert.AreEqual("42", idMessage.Ids[0]); - // null should serialize as "null" JSON literal - Assert.AreEqual("null", idMessage.Ids[1]); - Assert.AreEqual("\"text\"", idMessage.Ids[2]); - } - - [TestMethod] - public void IdMessage_WithSpecialCharactersInString_EscapesCorrectly() - { - var idMessage = new IdMessage("hello \"world\" \n\t\\"); - - Assert.AreEqual(1, idMessage.Ids.Count); - // Should contain escaped characters - Assert.IsTrue(idMessage.Ids[0].Contains("\\\"")); - } - - [TestMethod] - public void IdMessage_WithEmptyString_SerializesAsEmptyQuotedString() - { - var idMessage = new IdMessage(""); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.AreEqual("\"\"", idMessage.Ids[0]); - } - - [TestMethod] - public void IdMessage_WithZeroValues_SerializesCorrectly() - { - var idMessage = new IdMessage(new object[] { 0, 0L, 0.0, 0m }); - - Assert.AreEqual(4, idMessage.Ids.Count); - Assert.AreEqual("0", idMessage.Ids[0]); - Assert.AreEqual("0", idMessage.Ids[1]); - Assert.AreEqual("0", idMessage.Ids[2]); - Assert.AreEqual("0", idMessage.Ids[3]); - } - - [TestMethod] - public void IdMessage_WithGuidEmpty_SerializesCorrectly() - { - var idMessage = new IdMessage(Guid.Empty); - - Assert.AreEqual(1, idMessage.Ids.Count); - Assert.IsTrue(idMessage.Ids[0].Contains("00000000-0000-0000-0000-000000000000")); - } - - #endregion - - #region MessagePack Serialization Round-Trip Tests - - [TestMethod] - public void SignalPostJsonDataMessage_MessagePackRoundTrip_PreservesPostDataJson() - { - // Arrange - var original = new SignalPostJsonDataMessage(new IdMessage(42)); - - // Act - Serialize to MessagePack - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - - // Deserialize back - var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); - - // Assert - Assert.IsNotNull(deserialized); - Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null after deserialization"); - Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson), "PostDataJson should not be empty"); - - // Verify PostData can be accessed - Assert.IsNotNull(deserialized.PostData); - Assert.AreEqual(1, deserialized.PostData.Ids.Count); - Assert.AreEqual("42", deserialized.PostData.Ids[0]); - } - - [TestMethod] - public void SignalPostJsonDataMessage_WithComplexObject_MessagePackRoundTrip() - { - // Arrange - var order = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 5 }; - var original = new SignalPostJsonDataMessage(order); - - // Act - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); - - // Assert - Assert.IsNotNull(deserialized); - Assert.IsNotNull(deserialized.PostDataJson); - Assert.IsNotNull(deserialized.PostData); - Assert.AreEqual("Test", deserialized.PostData.ProductName); - Assert.AreEqual(5, deserialized.PostData.Quantity); - } - - [TestMethod] - public void SignalPostJsonMessage_BaseClass_MessagePackRoundTrip() - { - // Test base class directly - var original = new SignalPostJsonMessage { PostDataJson = "{\"test\":123}" }; - - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - var deserialized = bytes.MessagePackTo(ContractlessStandardResolver.Options); - - Assert.IsNotNull(deserialized); - Assert.AreEqual("{\"test\":123}", deserialized.PostDataJson); - } - - [TestMethod] - public void IdMessage_InSignalPostJsonDataMessage_RoundTrip_PreservesAllIds() - { - // Arrange - Multiple IDs - var idMessage = new IdMessage(new object[] { 1, "test", true, Guid.Empty }); - var original = new SignalPostJsonDataMessage(idMessage); - - // Act - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); - - // Assert - Assert.IsNotNull(deserialized?.PostData); - Assert.AreEqual(4, deserialized.PostData.Ids.Count); - Assert.AreEqual("1", deserialized.PostData.Ids[0]); - Assert.AreEqual("\"test\"", deserialized.PostData.Ids[1]); - Assert.AreEqual("true", deserialized.PostData.Ids[2]); - } - - [TestMethod] - public void SignalPostJsonDataMessage_DeserializeAsBaseType_WorksCorrectly() - { - // This simulates what the server does - deserializing as SignalPostJsonMessage (base type) - var original = new SignalPostJsonDataMessage(new IdMessage(42)); - - // Serialize as derived type - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - - // Deserialize as BASE type (like server might do) - var deserialized = bytes.MessagePackTo(ContractlessStandardResolver.Options); - - // Assert - PostDataJson should still be available - Assert.IsNotNull(deserialized); - Assert.IsNotNull(deserialized.PostDataJson); - Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson)); - - // Should be able to manually deserialize the JSON - var idMessage = deserialized.PostDataJson.JsonTo(); - Assert.IsNotNull(idMessage); - Assert.AreEqual(1, idMessage.Ids.Count); - } - - [TestMethod] - public void SignalPostJsonDataMessage_WithIdMessage_ContainingInt_RoundTrip() - { - // Arrange - This is exactly what the client does when calling PostDataAsync - var idMessage = new IdMessage(42); - var original = new SignalPostJsonDataMessage(idMessage); - - // Debug: print what's in PostDataJson - Console.WriteLine($"IdMessage.Ids[0]: {idMessage.Ids[0]}"); - Console.WriteLine($"Original PostDataJson: {original.PostDataJson}"); - - // Act - Serialize to MessagePack (what client does) - var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); - Console.WriteLine($"MessagePack bytes length: {bytes.Length}"); - - // Deserialize back (what server does) - var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); - - // Assert - Assert.IsNotNull(deserialized); - Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}"); - Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null"); - Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson), "PostDataJson should not be empty"); - - // This is the key - PostData should be accessible - Assert.IsNotNull(deserialized.PostData, "PostData should be deserializable"); - Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData.Ids.Count}"); - Assert.AreEqual(1, deserialized.PostData.Ids.Count, "Should have 1 Id"); - Assert.AreEqual("42", deserialized.PostData.Ids[0], "Id should be '42'"); - } - - #endregion -} diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs index 7d08301..d36553c 100644 --- a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs +++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs @@ -10,7 +10,6 @@ public class PostJsonDataMessageTests [TestMethod] public void Debug_CreatePostMessage_ForInt() { - // Test what CreatePostMessage produces for an int var message = CreatePostMessageTest(42); Console.WriteLine($"Message type: {message.GetType().Name}"); @@ -22,11 +21,9 @@ public class PostJsonDataMessageTests Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}"); } - // Serialize to MessagePack var bytes = message.ToMessagePack(ContractlessStandardResolver.Options); Console.WriteLine($"MessagePack bytes: {bytes.Length}"); - // Deserialize as server would var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}"); Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}"); @@ -52,68 +49,49 @@ public class PostJsonDataMessageTests return null; } - // Step 1: Client creates message for int parameter (like PostDataAsync) Console.WriteLine("=== Step 1: Client creates message ==="); - var idMessage = new IdMessage(GetValueByType(testValue)); Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'"); var clientMessage = new SignalPostJsonDataMessage(idMessage); Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'"); - // Step 2: Serialize to MessagePack (client sends) Console.WriteLine("\n=== Step 2: MessagePack serialization ==="); var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options); Console.WriteLine($"MessagePack bytes: {bytes.Length}"); - // Step 3: Server deserializes Console.WriteLine("\n=== Step 3: Server deserializes ==="); var serverMessage = bytes.MessagePackTo>(ContractlessStandardResolver.Options); Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'"); Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}"); Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'"); - // Step 4: Server deserializes parameter Console.WriteLine("\n=== Step 4: Server deserializes parameter ==="); var paramJson = serverMessage.PostData.Ids[0]; Console.WriteLine($"Parameter JSON: '{paramJson}'"); var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType()); - Console.WriteLine($"Deserialized int value: {paramValue}"); + Console.WriteLine($"Deserialized value: {paramValue}"); - // Step 5: Service method returns string Console.WriteLine("\n=== Step 5: Service method returns ==="); - var serviceResult = $"{paramValue}"; // Like HandleSingleInt does + var serviceResult = $"{paramValue}"; Console.WriteLine($"Service result: '{serviceResult}'"); - // Step 6: Server creates response Console.WriteLine("\n=== Step 6: Server creates response ==="); - var response = new SignalResponseJsonMessage(100, SignalResponseStatus.Success, serviceResult); - Console.WriteLine($"Response.ResponseData: '{response.ResponseData}'"); + var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AcJsonSerializerOptions.Default); + Console.WriteLine($"Response created with Binary bytes: {response.ResponseDataBin?.Length ?? 0}"); - // Step 7: Serialize response to MessagePack - Console.WriteLine("\n=== Step 7: Response MessagePack ==="); - var responseBytes = response.ToMessagePack(ContractlessStandardResolver.Options); - Console.WriteLine($"Response MessagePack bytes: {responseBytes.Length}"); + Console.WriteLine("\n=== Step 7: Response Binary ==="); + var responseBytes = response.ToBinary(); + Console.WriteLine($"Response Binary bytes: {responseBytes.Length}"); - // Step 8: Client deserializes response Console.WriteLine("\n=== Step 8: Client deserializes response ==="); - var clientResponse = responseBytes.MessagePackTo(ContractlessStandardResolver.Options); - Console.WriteLine($"Client ResponseData: '{clientResponse.ResponseData}'"); + var clientResponse = responseBytes.BinaryTo(); + Console.WriteLine($"Client Response Status: {clientResponse?.Status}"); - // Step 9: Client deserializes to target type (string) Console.WriteLine("\n=== Step 9: Client deserializes to string ==="); - try - { - var finalResult = clientResponse.ResponseData.JsonTo(); - - Console.WriteLine($"Final result: '{finalResult}'"); - Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: {ex.Message}"); - throw; - } + var finalResult = clientResponse?.GetResponseData(); + Console.WriteLine($"Final result: '{finalResult}'"); + Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult); } private static ISignalRMessage CreatePostMessageTest(TPostData postData) @@ -121,9 +99,7 @@ public class PostJsonDataMessageTests var type = typeof(TPostData); if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime)) - { return new SignalPostJsonDataMessage(new IdMessage(postData!)); - } return new SignalPostJsonDataMessage(postData); } diff --git a/AyCode.Services.Tests/SignalRs/TestClientTags.cs b/AyCode.Services.Tests/SignalRs/TestClientTags.cs deleted file mode 100644 index 32599e2..0000000 --- a/AyCode.Services.Tests/SignalRs/TestClientTags.cs +++ /dev/null @@ -1,30 +0,0 @@ -using AyCode.Services.SignalRs; - -namespace AyCode.Services.Tests.SignalRs; - -/// -/// SignalR message tags for client testing. -/// -public static class TestClientTags -{ - // Basic operations - public const int Ping = 1; - public const int Echo = 2; - public const int GetStatus = 3; - - // CRUD operations - public const int GetById = 10; - public const int GetAll = 11; - public const int Create = 12; - public const int Update = 13; - public const int Delete = 14; - - // Complex operations - public const int GetOrderWithItems = 20; - public const int PostOrder = 21; - public const int GetMultipleParams = 22; - - // Error scenarios - public const int NotFound = 100; - public const int ServerError = 101; -} diff --git a/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs b/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs index 9f69780..707b5a8 100644 --- a/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs +++ b/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs @@ -109,8 +109,8 @@ public class TestableSignalRClient : AcSignalRClientBase /// public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null) { - var response = new SignalResponseJsonMessage(messageTag, status, data); - var bytes = response.ToMessagePack(ContractlessStandardResolver.Options); + var response = new SignalResponseDataMessage(messageTag, status, data, AcJsonSerializerOptions.Default); + var bytes = response.ToBinary(); return OnReceiveMessage(messageTag, bytes, requestId); } @@ -217,11 +217,11 @@ public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes) /// /// Deserializes the message as a response. /// - public SignalResponseJsonMessage? AsResponse() + public SignalResponseDataMessage? AsResponse() { try { - return MessageBytes.MessagePackTo(ContractlessStandardResolver.Options); + return MessageBytes.BinaryTo(); } catch { diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index 4c1bb1b..efbf6fb 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -5,11 +5,8 @@ using AyCode.Core.Helpers; using AyCode.Core.Loggers; using AyCode.Interfaces.Entities; using MessagePack.Resolvers; -using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.Logging; -using static AyCode.Core.Extensions.JsonUtilities; namespace AyCode.Services.SignalRs { @@ -20,7 +17,6 @@ namespace AyCode.Services.SignalRs protected readonly HubConnection? HubConnection; protected readonly AcLoggerBase Logger; - //protected event Action OnMessageReceived = null!; protected abstract Task MessageReceived(int messageTag, byte[] messageBytes); public int MsDelay = 25; @@ -30,67 +26,30 @@ namespace AyCode.Services.SignalRs public int TransportSendTimeout = 60000; private const string TagsName = "SignalRTags"; - /// - /// Production constructor - creates and starts HubConnection. - /// protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger) { Logger = logger; Logger.Detail(fullHubName); - //TODO: HubConnectionBuilder constructor!!! - J. HubConnection = new HubConnectionBuilder() - //.WithUrl(fullHubName) .WithUrl(fullHubName, HttpTransportType.WebSockets, options => { - options.TransportMaxBufferSize = 30_000_000; //Increasing this value allows the client to receive larger messages. default: 65KB; unlimited: 0;; - options.ApplicationMaxBufferSize = 30_000_000; //Increasing this value allows the client to send larger messages. default: 65KB; unlimited: 0; - options.CloseTimeout = TimeSpan.FromSeconds(10); //default: 5 sec. - options.SkipNegotiation = true; // Skip HTTP negotiation when using WebSockets only - - //options.AccessTokenProvider = null; - //options.HttpMessageHandlerFactory = null; - //options.Headers["CustomData"] = "value"; - //options.SkipNegotiation = true; - //options.ClientCertificates = new System.Security.Cryptography.X509Certificates.X509CertificateCollection(); - //options.Cookies = new System.Net.CookieContainer(); - //options.DefaultTransferFormat = TransferFormat.Text; - //options.Credentials = null; - //options.Proxy = null; - //options.UseDefaultCredentials = true; - //options.WebSocketConfiguration = null; - //options.WebSocketFactory = null; + options.TransportMaxBufferSize = 30_000_000; + options.ApplicationMaxBufferSize = 30_000_000; + options.CloseTimeout = TimeSpan.FromSeconds(10); + options.SkipNegotiation = true; }) - //.ConfigureLogging(logging => - //{ - // logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); - // logging.AddConsole(); - //}) .WithAutomaticReconnect() .WithStatefulReconnect() .WithKeepAliveInterval(TimeSpan.FromSeconds(60)) .WithServerTimeout(TimeSpan.FromSeconds(180)) - //.AddMessagePackProtocol(options => { - // options.SerializerOptions = MessagePackSerializerOptions.Standard - // .WithResolver(MessagePack.Resolvers.StandardResolver.Instance) - // .WithSecurity(MessagePackSecurity.UntrustedData) - // .WithCompression(MessagePackCompression.Lz4Block) - // .WithCompressionMinLength(256);}) .Build(); HubConnection.Closed += HubConnection_Closed; - _ = HubConnection.On(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); - //_ = HubConnection.On(nameof(IAcSignalRHubClient.OnRequestMessage), OnRequestMessage); - - //HubConnection.StartAsync().Forget(); } - /// - /// Test constructor - allows testing without real HubConnection. - /// Override virtual methods to control behavior in tests. - /// protected AcSignalRClientBase(AcLoggerBase logger) { Logger = logger; @@ -99,96 +58,40 @@ namespace AyCode.Services.SignalRs private Task HubConnection_Closed(Exception? arg) { - if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed"); + if (_responseByRequestId.IsEmpty) Logger.DebugConditional("Client HubConnection_Closed"); else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}"); ClearPendingRequests(); return Task.CompletedTask; } - #region Connection State Methods (virtual for testing) + #region Connection State Methods - /// - /// Gets the current connection state. Override in tests. - /// protected virtual HubConnectionState GetConnectionState() => HubConnection?.State ?? HubConnectionState.Disconnected; - /// - /// Checks if the connection is connected. Override in tests. - /// protected virtual bool IsConnected() => GetConnectionState() == HubConnectionState.Connected; - /// - /// Starts the connection. Override in tests to avoid real connection. - /// protected virtual Task StartConnectionInternal() - { - if (HubConnection == null) return Task.CompletedTask; - return HubConnection.StartAsync(); - } + => HubConnection?.StartAsync() ?? Task.CompletedTask; - /// - /// Stops the connection. Override in tests. - /// protected virtual Task StopConnectionInternal() - { - if (HubConnection == null) return Task.CompletedTask; - return HubConnection.StopAsync(); - } + => HubConnection?.StopAsync() ?? Task.CompletedTask; - /// - /// Disposes the connection. Override in tests. - /// protected virtual ValueTask DisposeConnectionInternal() - { - if (HubConnection == null) return ValueTask.CompletedTask; - return HubConnection.DisposeAsync(); - } + => HubConnection?.DisposeAsync() ?? ValueTask.CompletedTask; - /// - /// Sends a message to the server via HubConnection. Override in tests. - /// protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) - { - if (HubConnection == null) return Task.CompletedTask; - return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId); - } + => HubConnection?.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId) ?? Task.CompletedTask; #endregion #region Protected Test Helpers - /// - /// Gets the pending requests dictionary for testing. - /// - protected ConcurrentDictionary GetPendingRequests() - => _responseByRequestId; - - /// - /// Clears all pending requests. - /// - protected void ClearPendingRequests() - => _responseByRequestId.Clear(); - - /// - /// Registers a pending request for testing. - /// - protected void RegisterPendingRequest(int requestId, SignalRRequestModel model) - => _responseByRequestId[requestId] = model; - - /// - /// Simulates receiving a response for testing. - /// - protected void SimulateResponse(int requestId, ISignalResponseMessage response) - { - if (_responseByRequestId.TryGetValue(requestId, out var model)) - { - model.ResponseByRequestId = response; - model.ResponseDateTime = DateTime.UtcNow; - } - } + protected ConcurrentDictionary GetPendingRequests() => _responseByRequestId; + protected void ClearPendingRequests() => _responseByRequestId.Clear(); + protected void RegisterPendingRequest(int requestId, SignalRRequestModel model) => _responseByRequestId[requestId] = model; #endregion @@ -228,105 +131,82 @@ namespace AyCode.Services.SignalRs } #region CRUD - public virtual Task PostAsync(int messageTag, object parameter) //where TResponseData : class + + public virtual Task PostAsync(int messageTag, object parameter) => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameter)), GetNextRequestId()); - public virtual Task PostAsync(int messageTag, object[] parameters) //where TResponseData : class + public virtual Task PostAsync(int messageTag, object[] parameters) => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameters)), GetNextRequestId()); - public virtual Task GetByIdAsync(int messageTag, object id) //where TResponseData : class + public virtual Task GetByIdAsync(int messageTag, object id) => PostAsync(messageTag, id); - public virtual Task GetByIdAsync(int messageTag, Func, Task> responseCallback, object id) - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(id)), responseCallback); - - public virtual Task GetByIdAsync(int messageTag, object[] ids) //where TResponseData : class + public virtual Task GetByIdAsync(int messageTag, object[] ids) => PostAsync(messageTag, ids); - public virtual Task GetByIdAsync(int messageTag, Func, Task> responseCallback, object[] ids) - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(ids)), responseCallback); - - public virtual Task GetAllAsync(int messageTag) //where TResponseData : class + public virtual Task GetAllAsync(int messageTag) => SendMessageToServerAsync(messageTag); - public virtual Task GetAllAsync(int messageTag, Func, Task> responseCallback) - => SendMessageToServerAsync(messageTag, null, responseCallback); - public virtual Task GetAllAsync(int messageTag, Func, Task> responseCallback, object[]? contextParams) - => SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams))), responseCallback); - public virtual Task GetAllAsync(int messageTag, object[]? contextParams) //where TResponseData : class + public virtual Task GetAllAsync(int messageTag, object[]? contextParams) => SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)), GetNextRequestId()); public virtual Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); - public virtual Task PostDataAsync(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class + + public virtual Task PostDataAsync(int messageTag, TPostData postData) => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); - public virtual Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) //where TPostData : class - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); - public virtual Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) //where TPostData : class where TResponseData : class - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); - /// - /// Creates the appropriate message wrapper for the post data. - /// Primitives, strings, enums, and value types are wrapped in IdMessage. - /// Complex objects are sent directly in SignalPostJsonDataMessage. + /// Posts data and invokes callback with response. Fire-and-forget friendly for background saves. /// + public virtual Task PostDataAsync(int messageTag, TPostData postData, Action responseCallback) + { + var requestId = GetNextRequestId(); + var requestModel = SignalRRequestModelPool.Get(new Action(response => + { + if (response is SignalResponseDataMessage dataMsg) + responseCallback(dataMsg); + else + Logger.Error($"PostDataAsync callback received unexpected message type: {response.GetType().Name}"); + })); + + _responseByRequestId[requestId] = requestModel; + return SendMessageToServerAsync(messageTag, CreatePostMessage(postData), requestId); + } + private static ISignalRMessage CreatePostMessage(TPostData postData) { var type = typeof(TPostData); - - // Primitives, strings, enums, and value types should use IdMessage format - if (IsPrimitiveOrStringOrEnum(type)) - { + if (type == typeof(string) || type.IsEnum || type.IsValueType) return new SignalPostJsonDataMessage(new IdMessage(postData!)); - } - - // Complex objects use direct serialization return new SignalPostJsonDataMessage(postData); } - /// - /// Determines if a type should use IdMessage format (primitives, strings, enums, value types). - /// Must match the logic in AcWebSignalRHubBase.IsPrimitiveOrStringOrEnum. - /// NOTE: Arrays and collections are NOT included here - they are complex objects for PostDataAsync. - /// - private static bool IsPrimitiveOrStringOrEnum(Type type) - { - return type == typeof(string) || - type.IsEnum || - type.IsValueType || - type == typeof(DateTime); - } - public Task GetAllIntoAsync(List intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid { - return GetAllAsync>(messageTag, response => + return GetAllAsync>(messageTag, contextParams).ContinueWith(task => { - var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); status: {response.Status}; dataCount: {response.ResponseData?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};"; - + var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); dataCount: {task.Result?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};"; intoList.Clear(); - - if (response.Status == SignalResponseStatus.Success && response.ResponseData != null) + if (task.Result != null) { Logger.Debug(logText); - intoList.AddRange(response.ResponseData); + intoList.AddRange(task.Result); } else Logger.Error(logText); - callback?.Invoke(); - return Task.CompletedTask; - }, contextParams); + }, TaskScheduler.Default); } - #endregion CRUD + #endregion - public virtual Task SendMessageToServerAsync(int messageTag) //where TResponse : class + public virtual Task SendMessageToServerAsync(int messageTag) => SendMessageToServerAsync(messageTag, null, GetNextRequestId()); - public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message) //where TResponse : class + public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message) => SendMessageToServerAsync(messageTag, message, GetNextRequestId()); - protected virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class + protected virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int requestId) { Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); @@ -339,7 +219,7 @@ namespace AyCode.Services.SignalRs try { if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) && - _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage) + _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is SignalResponseDataMessage responseMessage) { startTime = obj.RequestDateTime; SignalRRequestModelPool.Return(obj); @@ -351,20 +231,15 @@ namespace AyCode.Services.SignalRs return await Task.FromException(new Exception(errorText)); } - var responseData = DeserializeResponseData(responseMessage); + var responseData = responseMessage.GetResponseData(); if (responseData == null && responseMessage.Status == SignalResponseStatus.Success) { - // Null response is valid for Success status Logger.Info($"Client received null response. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); return default; } - var serializerType = responseMessage switch - { - SignalResponseBinaryMessage => "Binary", - _ => "JSON" - }; + var serializerType = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON"; Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); return responseData; } @@ -377,62 +252,11 @@ namespace AyCode.Services.SignalRs } if (_responseByRequestId.TryRemove(requestId, out var removedModel)) - { SignalRRequestModelPool.Return(removedModel); - } + return default; } - /// - /// Deserializes response data from either JSON or Binary format. - /// Automatically detects the format based on the response message type. - /// - private static TResponse? DeserializeResponseData(ISignalResponseMessage responseMessage) - { - return responseMessage switch - { - SignalResponseBinaryMessage binaryMsg when binaryMsg.ResponseData != null - => binaryMsg.ResponseData.BinaryTo(), - - SignalResponseJsonMessage jsonMsg when !string.IsNullOrEmpty(jsonMsg.ResponseData) - => jsonMsg.ResponseData.JsonTo(), - - ISignalResponseMessage stringMsg when !string.IsNullOrEmpty(stringMsg.ResponseData) - => stringMsg.ResponseData.JsonTo(), - - _ => default - }; - } - - public virtual Task SendMessageToServerAsync(int messageTag, Func, Task> responseCallback) - => SendMessageToServerAsync(messageTag, null, responseCallback); - - public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func, Task> responseCallback) - { - if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0"); - - var requestId = GetNextRequestId(); - var requestModel = SignalRRequestModelPool.Get(new Action(responseMessage => - { - TResponseData? responseData = default; - - if (responseMessage.Status == SignalResponseStatus.Success) - { - responseData = DeserializeResponseData(responseMessage); - } - else Logger.Error($"Client SendMessageToServerAsync response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); - - responseCallback(new SignalResponseMessage(messageTag, responseMessage.Status, responseData)); - })); - - _responseByRequestId[requestId] = requestModel; - - return SendMessageToServerAsync(messageTag, message, requestId); - } - - /// - /// Gets the next unique request ID. - /// protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32; public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId) @@ -443,64 +267,44 @@ namespace AyCode.Services.SignalRs try { - if (requestId.HasValue && _responseByRequestId.ContainsKey(requestId.Value)) + if (requestId.HasValue && _responseByRequestId.TryGetValue(requestId.Value, out var requestModel)) { var reqId = requestId.Value; + requestModel.ResponseDateTime = DateTime.UtcNow; + Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{messageBytes.Length / 1024}kb]{logText}"); - _responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow; - Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}"); + var responseMessage = messageBytes.BinaryTo() ?? new SignalResponseDataMessage(); - var responseMessage = DeserializeResponseMessage(messageBytes); - - switch (_responseByRequestId[reqId].ResponseByRequestId) + switch (requestModel.ResponseByRequestId) { case null: - _responseByRequestId[reqId].ResponseByRequestId = responseMessage; + requestModel.ResponseByRequestId = responseMessage; return Task.CompletedTask; case Action messageCallback: if (_responseByRequestId.TryRemove(reqId, out var callbackModel)) - { SignalRRequestModelPool.Return(callbackModel); - } - messageCallback.Invoke(responseMessage); return Task.CompletedTask; - // Legacy support for string-based callbacks - case Action> stringCallback when responseMessage is SignalResponseJsonMessage jsonMsg: - if (_responseByRequestId.TryRemove(reqId, out var legacyModel)) - { - SignalRRequestModelPool.Return(legacyModel); - } - - stringCallback.Invoke(jsonMsg); - return Task.CompletedTask; - default: - Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); + Logger.Error($"Client OnReceiveMessage switch; unknown message type: {requestModel.ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); break; } if (_responseByRequestId.TryRemove(reqId, out var removedModel)) - { SignalRRequestModelPool.Return(removedModel); - } - // Request-response hibás eset - ne hívjuk meg a MessageReceived-et return Task.CompletedTask; } - // Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et Logger.Info(logText); MessageReceived(messageTag, messageBytes).Forget(); } catch (Exception ex) { if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel)) - { SignalRRequestModelPool.Return(exModel); - } Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); throw; @@ -508,33 +312,5 @@ namespace AyCode.Services.SignalRs return Task.CompletedTask; } - - /// - /// Deserializes a MessagePack response to the appropriate message type (JSON or Binary). - /// Uses DetectSerializerTypeFromBytes to determine the format of the ResponseData. - /// - protected virtual ISignalResponseMessage DeserializeResponseMessage(byte[] messageBytes) - { - // First, try to deserialize as Binary message to check the ResponseData format - try - { - var binaryMsg = messageBytes.MessagePackTo(ContractlessStandardResolver.Options); - if (binaryMsg.ResponseData != null && binaryMsg.ResponseData.Length > 0) - { - // Use the existing utility to detect if ResponseData is Binary format - if (DetectSerializerTypeFromBytes(binaryMsg.ResponseData) == AcSerializerType.Binary) - { - return binaryMsg; - } - } - } - catch - { - // Failed to deserialize as Binary message - } - - // Fall back to JSON format - return messageBytes.MessagePackTo(ContractlessStandardResolver.Options); - } } } diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 0116745..bdac7e5 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -1,10 +1,9 @@ using AyCode.Core.Extensions; using MessagePack; -using Newtonsoft.Json.Linq; -using System.Text.RegularExpressions; using AyCode.Core.Interfaces; -using System.Collections.Generic; -using System.Linq.Expressions; +using AyCode.Core.Compression; +using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute; +using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace AyCode.Services.SignalRs; @@ -24,7 +23,6 @@ public class IdMessage /// public IdMessage(object[] ids) { - // Pre-allocate capacity to avoid list resizing Ids = new List(ids.Length); for (var i = 0; i < ids.Length; i++) { @@ -38,7 +36,6 @@ public class IdMessage /// public IdMessage(object id) { - // Pre-allocate for single item Ids = new List(1) { id.ToJson() }; } @@ -48,7 +45,6 @@ public class IdMessage /// public IdMessage(IEnumerable ids) { - // Materialize to array once to get count and avoid multiple enumeration var idsArray = ids as Guid[] ?? ids.ToArray(); Ids = new List(idsArray.Length); for (var i = 0; i < idsArray.Length; i++) @@ -57,10 +53,7 @@ public class IdMessage } } - public override string ToString() - { - return string.Join("; ", Ids); - } + public override string ToString() => string.Join("; ", Ids); } [MessagePackObject] @@ -69,24 +62,24 @@ public class SignalPostJsonMessage [Key(0)] public string PostDataJson { get; set; } = ""; - public SignalPostJsonMessage() - {} + public SignalPostJsonMessage() { } protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson; } [MessagePackObject(AllowPrivate = false)] -public class SignalPostJsonDataMessage : SignalPostJsonMessage, ISignalPostMessage //where TPostDataType : class +public class SignalPostJsonDataMessage : SignalPostJsonMessage, ISignalPostMessage { [IgnoreMember] + [JsonIgnore] + [STJIgnore] private TPostDataType? _postData; [IgnoreMember] + [JsonIgnore] + [STJIgnore] public TPostDataType PostData { - get - { - return _postData ??= PostDataJson.JsonTo()!; - } + get => _postData ??= PostDataJson.JsonTo()!; private init { _postData = value; @@ -94,11 +87,9 @@ public class SignalPostJsonDataMessage : SignalPostJsonMessage, I } } - public SignalPostJsonDataMessage() : base() - {} + public SignalPostJsonDataMessage() : base() { } public SignalPostJsonDataMessage(TPostDataType postData) => PostData = postData; - public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) - {} + public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) { } } [MessagePackObject] @@ -125,148 +116,7 @@ public interface ISignalRequestMessage : ISignalRMessage TRequestId Id { get; set; } } -public interface ISignalRMessage -{ } - - -[MessagePackObject] -public sealed class SignalResponseJsonMessage : ISignalResponseMessage -{ - [Key(0)] public int MessageTag { get; set; } - - [Key(1)] public SignalResponseStatus Status { get; set; } - - [Key(2)] public string? ResponseData { get; set; } = null; - - [IgnoreMember] - public string? ResponseDataJson => ResponseData; - - public SignalResponseJsonMessage(){} - - public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status) - { - Status = status; - MessageTag = messageTag; - } - - /// - /// Creates a response with the given data serialized as JSON. - /// If responseData is already a JSON string (starts with { or [), it will be used directly. - /// All other data types are serialized to JSON format. - /// - public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status) - { - if (responseData == null) - { - ResponseData = null; - return; - } - - // If responseData is already a JSON string, use it directly - if (responseData is string strData) - { - var trimmed = strData.Trim(); - if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']')) - { - // Already JSON - use directly without re-serialization - ResponseData = strData; - return; - } - } - - // Serialize to JSON - ResponseData = responseData.ToJson(); - } -} - -/// -/// Signal response message with lazy deserialization support. -/// ResponseData is only deserialized on first access and cached. -/// Use ResponseDataJson for direct JSON access without deserialization. -/// -[MessagePackObject(AllowPrivate = false)] -public sealed class SignalResponseMessage : ISignalResponseMessage -{ - [IgnoreMember] - private TResponseData? _responseData; - - [IgnoreMember] - private bool _isDeserialized; - - [Key(0)] - public int MessageTag { get; set; } - - [Key(1)] - public SignalResponseStatus Status { get; set; } - - /// - /// Raw JSON string. Use this for direct JSON access without triggering deserialization. - /// - [Key(2)] - public string? ResponseDataJson { get; set; } - - /// - /// Deserialized response data. Lazy-loaded on first access. - /// - [IgnoreMember] - public TResponseData? ResponseData - { - get - { - if (!_isDeserialized) - { - _isDeserialized = true; - - _responseData = ResponseDataJson != null - ? ResponseDataJson.JsonTo() - : default; - } - - return _responseData; - } - set - { - _isDeserialized = true; - _responseData = value; - ResponseDataJson = value?.ToJson(); - } - } - - public SignalResponseMessage() - { - } - - public SignalResponseMessage(int messageTag, SignalResponseStatus status) - { - MessageTag = messageTag; - Status = status; - } - - public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData) - : this(messageTag, status) - { - ResponseData = responseData; - } - - public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) - : this(messageTag, status) - { - ResponseDataJson = responseDataJson; - } -} - -public interface ISignalResponseMessage : ISignalResponseMessage -{ - /// - /// Deserialized response data. May trigger lazy deserialization. - /// - TResponseData? ResponseData { get; set; } - - /// - /// Raw JSON string for direct access without deserialization. - /// - string? ResponseDataJson { get; } -} +public interface ISignalRMessage { } public interface ISignalResponseMessage : ISignalRMessage { @@ -281,54 +131,154 @@ public enum SignalResponseStatus : byte } /// -/// Signal response message with binary serialized data. -/// Used when SerializerOptions.SerializerType == Binary for better performance. +/// Signal response message with lazy deserialization support. +/// Used for callback-based response handling. /// -[MessagePackObject] -public sealed class SignalResponseBinaryMessage : ISignalResponseMessage +[MessagePackObject(AllowPrivate = false)] +public sealed class SignalResponseMessage : ISignalResponseMessage { - [Key(0)] public int MessageTag { get; set; } - - [Key(1)] public SignalResponseStatus Status { get; set; } - - [Key(2)] public byte[]? ResponseData { get; set; } + [IgnoreMember] + [JsonIgnore] + [STJIgnore] + private TResponseData? _responseData; - [IgnoreMember] - public string? ResponseDataJson => ResponseData != null ? Convert.ToBase64String(ResponseData) : null; + [IgnoreMember] + [JsonIgnore] + [STJIgnore] + private bool _isDeserialized; - public SignalResponseBinaryMessage() { } + [Key(0)] + public int MessageTag { get; set; } + + [Key(1)] + public SignalResponseStatus Status { get; set; } + + [Key(2)] + public string? ResponseDataJson { get; set; } - public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status) + [IgnoreMember] + [JsonIgnore] + [STJIgnore] + public TResponseData? ResponseData { - Status = status; - MessageTag = messageTag; + get + { + if (!_isDeserialized) + { + _isDeserialized = true; + _responseData = ResponseDataJson != null ? ResponseDataJson.JsonTo() : default; + } + return _responseData; + } + set + { + _isDeserialized = true; + _responseData = value; + ResponseDataJson = value?.ToJson(); + } } - public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status, object? responseData, AcBinarySerializerOptions? options = null) + public SignalResponseMessage() { } + public SignalResponseMessage(int messageTag, SignalResponseStatus status) + { + MessageTag = messageTag; + Status = status; + } + public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData) : this(messageTag, status) + => ResponseData = responseData; + public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status) + => ResponseDataJson = responseDataJson; +} + +/// +/// Unified signal response message that supports both JSON and Binary serialization. +/// JSON mode uses Brotli compression for reduced payload size. +/// Optimized: decompression is performed only once and cached. +/// +public sealed class SignalResponseDataMessage : ISignalResponseMessage +{ + public int MessageTag { get; set; } + public SignalResponseStatus Status { get; set; } + public AcSerializerType DataSerializerType { get; set; } + public byte[]? ResponseDataBin { get; set; } + + [JsonIgnore] [STJIgnore] private string? _cachedJson; + + [JsonIgnore] [STJIgnore] private object? _cachedResponseData; + + public SignalResponseDataMessage() + { + } + + public SignalResponseDataMessage(int messageTag, SignalResponseStatus status) + { + MessageTag = messageTag; + Status = status; + } + + public SignalResponseDataMessage(int messageTag, SignalResponseStatus status, object? responseData, AcSerializerOptions serializerOptions) : this(messageTag, status) { + DataSerializerType = serializerOptions.SerializerType; if (responseData == null) { - ResponseData = null; + ResponseDataBin = null; return; } - // If responseData is already a byte array, use it directly - if (responseData is byte[] byteData) + if (serializerOptions.SerializerType == AcSerializerType.Binary) { - ResponseData = byteData; - return; - } + if (responseData is byte[] byteData) + { + ResponseDataBin = byteData; + return; + } - // Serialize to binary - ResponseData = options != null - ? responseData.ToBinary(options) - : responseData.ToBinary(); + var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default; + ResponseDataBin = responseData.ToBinary(binaryOptions); + } + else + { + string json; + if (responseData is string strData) + { + var trimmed = strData.Trim(); + if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']')) + json = strData; + else + { + var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default; + json = responseData.ToJson(jsonOptions); + } + } + else + { + var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default; + json = responseData.ToJson(jsonOptions); + } + + ResponseDataBin = BrotliHelper.Compress(json); + } + } + + /// + /// Deserializes the ResponseData to the specified type. + /// Uses cached decompressed JSON for repeated calls. + /// + public T? GetResponseData() + { + if (_cachedResponseData != null) return (T)_cachedResponseData; + if (ResponseDataBin == null) return default; + + if (DataSerializerType == AcSerializerType.Binary) return (T)(_cachedResponseData = ResponseDataBin.BinaryTo()); + + _cachedJson ??= BrotliHelper.DecompressToString(ResponseDataBin); + + return (T)(_cachedResponseData = _cachedJson.JsonTo()); } } public interface IAcSignalRHubClient : IAcSignalRHubBase { - Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId ); - //Task SendRequestToServerAsync(int messageTag, int requestId); + Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId); } \ No newline at end of file