diff --git a/AyCode.Core.Tests/Serialization/AcBinaryDateTimeSerializationTests.cs b/AyCode.Core.Tests/Serialization/AcBinaryDateTimeSerializationTests.cs new file mode 100644 index 0000000..0d89f4e --- /dev/null +++ b/AyCode.Core.Tests/Serialization/AcBinaryDateTimeSerializationTests.cs @@ -0,0 +1,473 @@ +using AyCode.Core.Extensions; + +namespace AyCode.Core.Tests.Serialization; + +/// +/// Tests for DateTime serialization in Binary format. +/// Covers edge cases like string-stored DateTime values in GenericAttribute-like scenarios. +/// +[TestClass] +public class AcBinaryDateTimeSerializationTests +{ + #region DateTime Direct Serialization + + [TestMethod] + public void DateTime_RoundTrip_PreservesValue() + { + var original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc); + + var binary = AcBinarySerializer.Serialize(original); + var result = binary.BinaryTo(); + + Assert.AreEqual(original, result); + Assert.AreEqual(DateTimeKind.Utc, result.Kind); + } + + [TestMethod] + public void NullableDateTime_RoundTrip_PreservesValue() + { + DateTime? original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc); + + var binary = AcBinarySerializer.Serialize(original); + var result = binary.BinaryTo(); + + Assert.AreEqual(original, result); + } + + [TestMethod] + public void NullableDateTime_Null_RoundTrip_PreservesNull() + { + DateTime? original = null; + + var binary = AcBinarySerializer.Serialize(original); + var result = binary.BinaryTo(); + + Assert.IsNull(result); + } + + #endregion + + #region Object with DateTime Property + + [TestMethod] + public void ObjectWithDateTime_RoundTrip_PreservesValue() + { + var original = new TestObjectWithDateTime + { + Id = 1, + CreatedAt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc) + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(original.Id, result.Id); + Assert.AreEqual(original.CreatedAt, result.CreatedAt); + Assert.AreEqual(original.UpdatedAt, result.UpdatedAt); + } + + [TestMethod] + public void ObjectWithNullableDateTime_Null_RoundTrip_PreservesNull() + { + var original = new TestObjectWithNullableDateTime + { + Id = 1, + DateOfReceipt = null + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(original.Id, result.Id); + Assert.IsNull(result.DateOfReceipt); + } + + [TestMethod] + public void ObjectWithNullableDateTime_HasValue_RoundTrip_PreservesValue() + { + var original = new TestObjectWithNullableDateTime + { + Id = 1, + DateOfReceipt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc) + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(original.Id, result.Id); + Assert.AreEqual(original.DateOfReceipt, result.DateOfReceipt); + } + + #endregion + + #region GenericAttribute-like Scenario (String stored DateTime) + + [TestMethod] + public void GenericAttributeScenario_DateTimeAsString_PreservesValue() + { + var original = new TestGenericAttribute + { + Key = "DateOfReceipt", + Value = "10/24/2025 00:27:00" + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(original.Key, result.Key); + Assert.AreEqual(original.Value, result.Value); + } + + [TestMethod] + public void GenericAttributeScenario_ZeroValue_PreservesValue() + { + var original = new TestGenericAttribute + { + Key = "DateOfReceipt", + Value = "0" + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(original.Key, result.Key); + Assert.AreEqual("0", result.Value); + } + + [TestMethod] + public void GenericAttributeList_RoundTrip_PreservesAllValues() + { + var original = new List + { + new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" }, + new() { Key = "SomeNumber", Value = "42" }, + new() { Key = "EmptyValue", Value = "" }, + new() { Key = "ZeroValue", Value = "0" } + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo>(); + + Assert.IsNotNull(result); + Assert.AreEqual(4, result.Count); + + Assert.AreEqual("DateOfReceipt", result[0].Key); + Assert.AreEqual("10/24/2025 00:27:00", result[0].Value); + + Assert.AreEqual("SomeNumber", result[1].Key); + Assert.AreEqual("42", result[1].Value); + + Assert.AreEqual("EmptyValue", result[2].Key); + Assert.AreEqual("", result[2].Value); + + Assert.AreEqual("ZeroValue", result[3].Key); + Assert.AreEqual("0", result[3].Value); + } + + [TestMethod] + public void ObjectWithGenericAttributes_RoundTrip_PreservesAllValues() + { + var original = new TestDtoWithGenericAttributes + { + Id = 123, + Name = "Test Order", + GenericAttributes = + [ + new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" }, + new() { Key = "Priority", Value = "1" } + ] + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(123, result.Id); + Assert.AreEqual("Test Order", result.Name); + Assert.AreEqual(2, result.GenericAttributes.Count); + + var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt"); + Assert.IsNotNull(dateAttr); + Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value); + } + + #endregion + + #region JSON vs Binary Comparison + + [TestMethod] + public void GenericAttribute_JsonAndBinary_ProduceSameResult() + { + var original = new TestGenericAttribute + { + Key = "DateOfReceipt", + Value = "10/24/2025 00:27:00" + }; + + var json = original.ToJson(); + var jsonResult = json.JsonTo(); + + var binary = original.ToBinary(); + var binaryResult = binary.BinaryTo(); + + Assert.IsNotNull(jsonResult); + Assert.IsNotNull(binaryResult); + + Assert.AreEqual(jsonResult.Key, binaryResult.Key); + Assert.AreEqual(jsonResult.Value, binaryResult.Value); + } + + [TestMethod] + public void DtoWithGenericAttributes_JsonAndBinary_ProduceSameResult() + { + var original = new TestDtoWithGenericAttributes + { + Id = 123, + Name = "Test Order", + GenericAttributes = + [ + new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" }, + new() { Key = "ZeroValue", Value = "0" } + ] + }; + + var json = original.ToJson(); + var jsonResult = json.JsonTo(); + + var binary = original.ToBinary(); + var binaryResult = binary.BinaryTo(); + + Assert.IsNotNull(jsonResult); + Assert.IsNotNull(binaryResult); + + Assert.AreEqual(jsonResult.Id, binaryResult.Id); + Assert.AreEqual(jsonResult.Name, binaryResult.Name); + Assert.AreEqual(jsonResult.GenericAttributes.Count, binaryResult.GenericAttributes.Count); + + for (int i = 0; i < jsonResult.GenericAttributes.Count; i++) + { + Assert.AreEqual(jsonResult.GenericAttributes[i].Key, binaryResult.GenericAttributes[i].Key); + Assert.AreEqual(jsonResult.GenericAttributes[i].Value, binaryResult.GenericAttributes[i].Value); + } + } + + #endregion + + #region Test Models + + public class TestObjectWithDateTime + { + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + public class TestObjectWithNullableDateTime + { + public int Id { get; set; } + public DateTime? DateOfReceipt { get; set; } + } + + public class TestGenericAttribute + { + public string Key { get; set; } = ""; + public string Value { get; set; } = ""; + } + + public class TestDtoWithGenericAttributes + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public List GenericAttributes { get; set; } = []; + } + + #endregion + + #region Exact Production Scenario Test + + /// + /// This test reproduces the exact production bug scenario: + /// 1. Server sends Binary serialized response with GenericAttributes + /// 2. Client deserializes the Binary response + /// 3. Client accesses DateOfReceipt property which reads from GenericAttributes + /// 4. CommonHelper2.To fails to parse the string value + /// + [TestMethod] + public void ProductionScenario_GenericAttributeWithDateString_PreservesExactFormat() + { + // Arrange: Create DTO with GenericAttributes like in production + var original = new TestDtoWithGenericAttributes + { + Id = 123, + Name = "Test Order", + GenericAttributes = + [ + new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" }, + new() { Key = "Priority", Value = "1" }, + new() { Key = "SomeFlag", Value = "true" } + ] + }; + + // Act: Binary round-trip (simulates server->client communication) + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + // Assert: The exact string value must be preserved + Assert.IsNotNull(result); + var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt"); + Assert.IsNotNull(dateAttr, "DateOfReceipt attribute should exist"); + + // This is the critical assertion - the EXACT string must be preserved + Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value, + $"Expected '10/24/2025 00:27:00' but got '{dateAttr.Value}'"); + + // Verify it can be parsed with US culture (which is how it was stored) + Assert.IsTrue(DateTime.TryParse(dateAttr.Value, new System.Globalization.CultureInfo("en-US"), + System.Globalization.DateTimeStyles.None, out var parsedDate), + $"Value '{dateAttr.Value}' should be parseable as US date format"); + + Assert.AreEqual(new DateTime(2025, 10, 24, 0, 27, 0), parsedDate); + } + + /// + /// Test that verifies the exact bytes of the string are preserved. + /// + [TestMethod] + public void ProductionScenario_StringWithSlashes_BytesArePreserved() + { + var original = "10/24/2025 00:27:00"; + var originalBytes = System.Text.Encoding.UTF8.GetBytes(original); + + // Serialize and deserialize + var binary = AcBinarySerializer.Serialize(original); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + var resultBytes = System.Text.Encoding.UTF8.GetBytes(result); + + // Compare byte-by-byte + Assert.AreEqual(originalBytes.Length, resultBytes.Length, "String length changed after serialization"); + for (int i = 0; i < originalBytes.Length; i++) + { + Assert.AreEqual(originalBytes[i], resultBytes[i], + $"Byte at position {i} differs: expected {originalBytes[i]:X2} ('{(char)originalBytes[i]}'), got {resultBytes[i]:X2} ('{(char)resultBytes[i]}')"); + } + } + + /// + /// Test with large list of GenericAttributes to catch any edge cases. + /// + [TestMethod] + public void ProductionScenario_ManyGenericAttributes_AllPreserved() + { + var original = new TestDtoWithGenericAttributes + { + Id = 999, + Name = "Large Order", + GenericAttributes = Enumerable.Range(0, 50).Select(i => new TestGenericAttribute + { + Key = $"Attribute_{i}", + Value = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString() + }).ToList() + }; + + var binary = original.ToBinary(); + var result = binary.BinaryTo(); + + Assert.IsNotNull(result); + Assert.AreEqual(50, result.GenericAttributes.Count); + + for (int i = 0; i < 50; i++) + { + var expectedValue = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString(); + Assert.AreEqual($"Attribute_{i}", result.GenericAttributes[i].Key); + Assert.AreEqual(expectedValue, result.GenericAttributes[i].Value, + $"Attribute_{i} value mismatch: expected '{expectedValue}', got '{result.GenericAttributes[i].Value}'"); + } + } + + #endregion + + #region CommonHelper2.To Simulation Tests + + /// + /// This test simulates what CommonHelper2.To does with various string values. + /// It helps identify which values will cause the FormatException. + /// + [TestMethod] + public void CommonHelperSimulation_ValidDateString_ParsesSuccessfully() + { + var dateString = "10/24/2025 00:27:00"; + + // This is what CommonHelper2.To does internally + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime)); + Assert.IsTrue(converter.CanConvertFrom(typeof(string))); + + // With InvariantCulture + var result = converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, dateString); + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(DateTime)); + var dt = (DateTime)result; + // InvariantCulture interprets 10/24/2025 as October 24, 2025 + Assert.AreEqual(2025, dt.Year); + Assert.AreEqual(10, dt.Month); + Assert.AreEqual(24, dt.Day); + } + + /// + /// This test shows that "0" cannot be parsed as DateTime - this is the actual bug! + /// + [TestMethod] + public void CommonHelperSimulation_ZeroString_ThrowsFormatException() + { + var invalidValue = "0"; + + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime)); + + // This should throw FormatException - exactly what we see in production + var threw = false; + try + { + converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue); + } + catch (FormatException) + { + threw = true; + } + + Assert.IsTrue(threw, "Converting '0' to DateTime should throw FormatException"); + } + + /// + /// Test various invalid DateTime strings that might be stored in GenericAttributes. + /// + [TestMethod] + [DataRow("0")] + [DataRow("null")] + [DataRow("undefined")] + [DataRow("N/A")] + public void CommonHelperSimulation_InvalidDateStrings_ThrowFormatException(string invalidValue) + { + var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime)); + + var threw = false; + try + { + converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue); + } + catch (FormatException) + { + threw = true; + } + + Assert.IsTrue(threw, $"Converting '{invalidValue}' to DateTime should throw FormatException"); + } + + #endregion +} diff --git a/AyCode.Core.Tests/TestModels/SharedTestModels.cs b/AyCode.Core.Tests/TestModels/SharedTestModels.cs index 82e7988..2a60223 100644 --- a/AyCode.Core.Tests/TestModels/SharedTestModels.cs +++ b/AyCode.Core.Tests/TestModels/SharedTestModels.cs @@ -284,6 +284,28 @@ public class TestGuidItem : IId #region Test-specific classes +/// +/// Simulates NopCommerce GenericAttribute - stores key-value pairs where DateTime values +/// are stored as strings in the database. +/// +public class TestGenericAttribute +{ + public int Id { get; set; } + public string Key { get; set; } = ""; + public string Value { get; set; } = ""; +} + +/// +/// DTO with GenericAttributes collection - simulates OrderDto with string-stored DateTime values. +/// This reproduces the production bug where Binary serialization was thought to corrupt DateTime strings. +/// +public class TestDtoWithGenericAttributes : IId +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public List GenericAttributes { get; set; } = []; +} + /// /// Order with nullable collections for null vs empty testing /// diff --git a/AyCode.Core/Extensions/AcBinaryDeserializer.cs b/AyCode.Core/Extensions/AcBinaryDeserializer.cs index 68eabb6..29b6908 100644 --- a/AyCode.Core/Extensions/AcBinaryDeserializer.cs +++ b/AyCode.Core/Extensions/AcBinaryDeserializer.cs @@ -288,6 +288,9 @@ public static class AcBinaryDeserializer /// /// Read a string and register it in the intern table for future references. + /// The serializer registers strings that meet MinStringInternLength (default: 4 chars), + /// then subsequent occurrences use StringInterned references. + /// We must register strings in the SAME order as the serializer to maintain index consistency. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string ReadAndInternString(ref BinaryDeserializationContext context) @@ -295,8 +298,16 @@ public static class AcBinaryDeserializer var length = (int)context.ReadVarUInt(); if (length == 0) return string.Empty; var str = context.ReadString(length); - // Register for future StringInterned references - context.RegisterInternedString(str); + // Always register strings that meet the minimum intern length threshold + // This must match the serializer's behavior exactly. + // The serializer checks value.Length (char count), not UTF-8 byte length. + // Default MinStringInternLength is 4. + // IMPORTANT: We register ALL strings >= 4 chars because the serializer does too, + // regardless of whether they will be referenced later via StringInterned. + if (str.Length >= context.MinStringInternLength) + { + context.RegisterInternedString(str); + } return str; } @@ -1085,6 +1096,12 @@ public static class AcBinaryDeserializer public byte FormatVersion { get; private set; } public bool HasMetadata { get; private set; } public bool HasReferenceHandling { get; private set; } + + /// + /// Minimum string length for interning. Must match serializer's MinStringInternLength. + /// Default: 4 (from AcBinarySerializerOptions) + /// + public byte MinStringInternLength { get; private set; } // Property name table private string[]? _propertyNames; @@ -1106,6 +1123,7 @@ public static class AcBinaryDeserializer FormatVersion = 0; HasMetadata = false; HasReferenceHandling = true; // Assume true by default + MinStringInternLength = 4; // Default from AcBinarySerializerOptions _propertyNames = null; _internedStrings = null; _references = null; diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs index 47670bf..9d91ddb 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs @@ -7,16 +7,18 @@ using MessagePack.Resolvers; namespace AyCode.Services.Server.Tests.SignalRs; /// -/// Integration tests for SignalR client-to-hub communication. +/// Base class for SignalR client-to-hub communication tests. /// Tests the full round-trip: Client -> Server -> Service -> Response -> Client +/// Derived classes specify the serializer type (JSON or Binary). /// -[TestClass] -public class SignalRClientToHubTest +public abstract class SignalRClientToHubTestBase { - private TestLogger _logger = null!; - private TestableSignalRClient2 _client = null!; - private TestableSignalRHub2 _hub = null!; - private TestSignalRService2 _service = null!; + protected abstract AcSerializerType SerializerType { get; } + + protected TestLogger _logger = null!; + protected TestableSignalRClient2 _client = null!; + protected TestableSignalRHub2 _hub = null!; + protected TestSignalRService2 _service = null!; [TestInitialize] public void Setup() @@ -25,6 +27,8 @@ public class SignalRClientToHubTest _hub = new TestableSignalRHub2(); _service = new TestSignalRService2(); _client = new TestableSignalRClient2(_hub, _logger); + + _hub.SetSerializerType(SerializerType); _hub.RegisterService(_service, _client); } @@ -162,7 +166,7 @@ public class SignalRClientToHubTest Assert.IsNotNull(result); Assert.AreEqual(1, result.Id); Assert.AreEqual("Processed: Widget", result.ProductName); - Assert.AreEqual(item.Quantity * 2, result.Quantity); // Doubled + Assert.AreEqual(item.Quantity * 2, result.Quantity); Assert.AreEqual(item.UnitPrice * 2, result.UnitPrice); } @@ -194,7 +198,7 @@ public class SignalRClientToHubTest #endregion - #region Collection Parameter Tests (using PostDataAsync for complex types) + #region Collection Parameter Tests [TestMethod] public async Task Post_IntArray_ReturnsDoubledValues() @@ -312,25 +316,15 @@ public class SignalRClientToHubTest CollectionAssert.AreEqual(input, result); } - /// - /// REGRESSION TEST: Tests GetAllAsync with empty array as context parameter. - /// Bug: When passing an empty array via GetAllAsync with contextParams, - /// the server receives an object instead of an array, causing deserialization failure. - /// Error: "JSON is an object but target type 'Int32[]' is a collection type" - /// [TestMethod] public async Task GetAll_WithEmptyArrayContextParam_ReturnsResult() { var result = await _client.GetAllAsync(TestSignalRTags.IntArrayParam, [Array.Empty()]); Assert.IsNotNull(result, "Result should not be null"); - // Empty array doubled is still empty array Assert.AreEqual(0, result.Length, "Empty array should return empty array"); } - /// - /// Tests GetAllAsync with non-empty array as context parameter. - /// [TestMethod] public async Task GetAll_WithArrayContextParam_ReturnsDoubledValues() { @@ -504,7 +498,7 @@ public class SignalRClientToHubTest #endregion - #region Async Task Method Tests - Critical for detecting non-awaited Tasks + #region Async Task Method Tests [TestMethod] public async Task Async_TestOrderItem_ReturnsProcessedItem() @@ -548,12 +542,11 @@ public class SignalRClientToHubTest #endregion - #region MessagePack Round-Trip Integrity Tests + #region Round-Trip Integrity Tests [TestMethod] - public async Task MessagePack_ComplexObject_PreservesAllProperties() + public async Task RoundTrip_ComplexObject_PreservesAllProperties() { - // Test that complex objects survive the full MessagePack round-trip var item = new TestOrderItem { Id = 999, @@ -568,14 +561,13 @@ public class SignalRClientToHubTest Assert.IsNotNull(result); Assert.AreEqual(999, result.Id); Assert.AreEqual("Processed: RoundTrip Test Item", result.ProductName); - Assert.AreEqual(100, result.Quantity); // Doubled by service - Assert.AreEqual(246.90m, result.UnitPrice); // Doubled by service + Assert.AreEqual(100, result.Quantity); + Assert.AreEqual(246.90m, result.UnitPrice); } [TestMethod] - public async Task MessagePack_NestedOrder_PreservesHierarchy() + public async Task RoundTrip_NestedOrder_PreservesHierarchy() { - // Test deeply nested object structure survives round-trip var order = TestDataFactory.CreateOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 1); var result = await _client.PostDataAsync(TestSignalRTags.TestOrderParam, order); @@ -585,7 +577,6 @@ public class SignalRClientToHubTest Assert.AreEqual(order.OrderNumber, result.OrderNumber); Assert.AreEqual(3, result.Items.Count); - // Verify nested items preserved for (int i = 0; i < 3; i++) { Assert.AreEqual(order.Items[i].Id, result.Items[i].Id); @@ -594,9 +585,8 @@ public class SignalRClientToHubTest } [TestMethod] - public async Task MessagePack_SpecialCharacters_PreservedCorrectly() + public async Task RoundTrip_SpecialCharacters_PreservedCorrectly() { - // Test that special characters survive JSON serialization in MessagePack payload var testString = "Special: \"quotes\" 'apostrophes' & ampersand \\ backslash \n newline"; var result = await _client.PostDataAsync(TestSignalRTags.StringParam, testString); @@ -606,9 +596,8 @@ public class SignalRClientToHubTest } [TestMethod] - public async Task MessagePack_UnicodeCharacters_PreservedCorrectly() + public async Task RoundTrip_UnicodeCharacters_PreservedCorrectly() { - // Test that Unicode characters survive the round-trip var testString = "Unicode: 中文 日本語 한국어 🎉 émoji"; var result = await _client.PostDataAsync(TestSignalRTags.StringParam, testString); @@ -618,7 +607,7 @@ public class SignalRClientToHubTest } [TestMethod] - public async Task MessagePack_EmptyString_PreservedCorrectly() + public async Task RoundTrip_EmptyString_PreservedCorrectly() { var result = await _client.PostDataAsync(TestSignalRTags.StringParam, ""); @@ -627,7 +616,7 @@ public class SignalRClientToHubTest } [TestMethod] - public async Task MessagePack_LargeDecimal_PreservedCorrectly() + public async Task RoundTrip_LargeDecimal_PreservedCorrectly() { var largeDecimal = 999999999999.999999m; @@ -637,7 +626,7 @@ public class SignalRClientToHubTest } [TestMethod] - public async Task MessagePack_ExtremeInt_PreservedCorrectly() + public async Task RoundTrip_ExtremeInt_PreservedCorrectly() { var result = await _client.PostDataAsync(TestSignalRTags.SingleIntParam, int.MaxValue); @@ -646,29 +635,24 @@ public class SignalRClientToHubTest #endregion - #region JSON Serialization Integrity Tests + #region Response Data Integrity Tests [TestMethod] - public async Task Json_ResponseData_NotDoubleEscaped() + public async Task ResponseData_NotDoubleEscaped() { - // This test ensures the JSON in ResponseData is not double-escaped - // which was the original bug we fixed var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 20m }; var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item); - // If double serialization occurred, result would be null or have wrong values Assert.IsNotNull(result); Assert.AreEqual(1, result.Id); Assert.AreEqual("Processed: Test", result.ProductName); - - // Verify numeric values survived correctly (would fail with double-escaped JSON) Assert.AreEqual(20, result.Quantity); Assert.AreEqual(40m, result.UnitPrice); } [TestMethod] - public async Task Json_CollectionResponse_DeserializesCorrectly() + public async Task CollectionResponse_DeserializesCorrectly() { var items = new List { @@ -690,16 +674,6 @@ public class SignalRClientToHubTest } } - /// - /// CRITICAL TEST: Ensures async Task<T> methods are properly awaited before serialization. - /// - /// Bug scenario: If an async method returns Task<TestOrderItem> and the framework - /// serializes the Task object instead of awaiting it, the JSON looks like: - /// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true} - /// - /// The actual data is wrapped in "Result" property, and Task metadata pollutes the response. - /// This test ensures we get the actual object, not the Task wrapper. - /// [TestMethod] public async Task Async_Method_ReturnsActualResult_NotTaskWrapper() { @@ -707,32 +681,18 @@ public class SignalRClientToHubTest var result = await _client.PostDataAsync(TestSignalRTags.AsyncTestOrderItemParam, item); - // If Task was serialized instead of awaited, result would have wrong values: - // - Id would still be correct (Task.Id coincidentally matches) - // - ProductName would be null/empty (not at root level) - // - Quantity would be 0 (not at root level) Assert.IsNotNull(result, "Result should not be null"); Assert.AreEqual(42, result.Id, "Id should match the original"); Assert.AreEqual("Async: TestProduct", result.ProductName, - "ProductName should be 'Async: TestProduct', not null. " + - "If null, the async method result was not properly awaited and Task was serialized."); - Assert.AreEqual(15, result.Quantity, - "Quantity should be tripled (5*3=15), not 0. " + - "If 0, the async method result was not properly awaited."); - Assert.AreEqual(30m, result.UnitPrice, - "UnitPrice should be tripled (10*3=30), not 0. " + - "If 0, the async method result was not properly awaited."); + "ProductName should be 'Async: TestProduct', not null."); + Assert.AreEqual(15, result.Quantity, "Quantity should be tripled (5*3=15)."); + Assert.AreEqual(30m, result.UnitPrice, "UnitPrice should be tripled (10*3=30)."); } #endregion - #region Task.FromResult Integration Tests - CRITICAL for production bug coverage + #region Task.FromResult Integration Tests - /// - /// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<string>. - /// This was the root cause of the production bug where methods returning Task - /// without async keyword caused Task wrapper to be serialized. - /// [TestMethod] public async Task TaskFromResult_String_ReturnsActualResult_NotTaskWrapper() { @@ -743,14 +703,6 @@ public class SignalRClientToHubTest "Should return the actual string, not Task wrapper JSON"); } - /// - /// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<TestOrderItem>. - /// This simulates the exact production bug scenario where complex objects returned - /// via Task.FromResult were serialized as Task wrapper instead of the actual object. - /// - /// Bug JSON output was: - /// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true} - /// [TestMethod] public async Task TaskFromResult_ComplexObject_ReturnsActualResult_NotTaskWrapper() { @@ -758,47 +710,26 @@ public class SignalRClientToHubTest var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultTestOrderItemParam, item); - // If Task wrapper was serialized, these assertions would fail: - // - ProductName would be null (nested inside "Result" property) - // - Quantity would be 0 - // - UnitPrice would be 0 Assert.IsNotNull(result, "Result should not be null"); Assert.AreEqual(42, result.Id, "Id should match the original"); Assert.AreEqual("FromResult: TestProduct", result.ProductName, - "ProductName should be 'FromResult: TestProduct'. " + - "If null, Task.FromResult result was not properly unwrapped."); - Assert.AreEqual(10, result.Quantity, - "Quantity should be doubled (5*2=10). " + - "If 0, Task.FromResult result was not properly unwrapped."); - Assert.AreEqual(20m, result.UnitPrice, - "UnitPrice should be doubled (10*2=20). " + - "If 0, Task.FromResult result was not properly unwrapped."); + "ProductName should be 'FromResult: TestProduct'."); + Assert.AreEqual(10, result.Quantity, "Quantity should be doubled (5*2=10)."); + Assert.AreEqual(20m, result.UnitPrice, "UnitPrice should be doubled (10*2=20)."); } - /// - /// Tests Task.FromResult<int> - primitive value type via Task.FromResult. - /// [TestMethod] public async Task TaskFromResult_Int_ReturnsActualResult_NotTaskWrapper() { var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultIntParam, 42); - Assert.AreEqual(84, result, - "Should return doubled value (42*2=84). " + - "If 0 or wrong value, Task.FromResult was not properly unwrapped."); + Assert.AreEqual(84, result, "Should return doubled value (42*2=84)."); } - /// - /// Tests Task.CompletedTask (non-generic Task) via SignalR. - /// [TestMethod] public async Task TaskFromResult_NoResult_CompletesSuccessfully() { - // This should not throw and should complete successfully var result = await _client.GetAllAsync(TestSignalRTags.TaskFromResultNoParams); - - // Non-generic Task returns null as there's no result value - // The important thing is the call completes without error } #endregion @@ -809,8 +740,7 @@ public class SignalRClientToHubTest public void InvokeMethod_SyncMethod_ReturnsValue() { var service = new TestSignalRService2(); - var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")! - ; + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")!; var result = methodInfo.InvokeMethod(service, 42); Assert.AreEqual("42", result); @@ -820,8 +750,7 @@ public class SignalRClientToHubTest public void InvokeMethod_AsyncTaskTMethod_ReturnsUnwrappedValue() { var service = new TestSignalRService2(); - var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")! - ; + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")!; var result = methodInfo.InvokeMethod(service, "Test"); @@ -857,10 +786,6 @@ public class SignalRClientToHubTest Assert.AreEqual(84, result); } - /// - /// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword. - /// This was the root cause of the production bug where Task wrapper was serialized. - /// [TestMethod] public void InvokeMethod_TaskFromResult_ReturnsUnwrappedValue() { @@ -886,7 +811,7 @@ public class SignalRClientToHubTest Assert.IsInstanceOfType(result, typeof(TestOrderItem)); var item = (TestOrderItem)result; Assert.AreEqual("FromResult: Widget", item.ProductName); - Assert.AreEqual(10, item.Quantity); // Doubled + Assert.AreEqual(10, item.Quantity); } [TestMethod] @@ -907,11 +832,194 @@ public class SignalRClientToHubTest var service = new TestSignalRService2(); var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultNoParams")!; - // Should complete without throwing var result = methodInfo.InvokeMethod(service); - - // Task.CompletedTask may return internal VoidTaskResult, the important thing is no exception } #endregion + + #region GenericAttributes Tests + + [TestMethod] + public async Task GenericAttributes_WithDateTimeString_PreservesExactValue() + { + var dto = new TestDtoWithGenericAttributes + { + Id = 123, + Name = "Test Order", + GenericAttributes = + [ + new TestGenericAttribute { Id = 1, Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" }, + new TestGenericAttribute { Id = 2, Key = "Priority", Value = "1" }, + new TestGenericAttribute { Id = 3, Key = "SomeFlag", Value = "true" } + ] + }; + + var result = await _client.PostDataAsync( + TestSignalRTags.GenericAttributesParam, dto); + + Assert.IsNotNull(result, "Result should not be null"); + Assert.AreEqual(123, result.Id); + Assert.AreEqual("Test Order", result.Name); + Assert.AreEqual(3, result.GenericAttributes.Count); + + var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt"); + Assert.IsNotNull(dateAttr, "DateOfReceipt attribute should exist"); + Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value); + + var priorityAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "Priority"); + Assert.IsNotNull(priorityAttr); + Assert.AreEqual("1", priorityAttr.Value); + + var flagAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "SomeFlag"); + Assert.IsNotNull(flagAttr); + Assert.AreEqual("true", flagAttr.Value); + } + + [TestMethod] + public async Task GenericAttributes_EmptyList_PreservesEmptyList() + { + var dto = new TestDtoWithGenericAttributes + { + Id = 789, + Name = "Empty Attributes", + GenericAttributes = [] + }; + + var result = await _client.PostDataAsync( + TestSignalRTags.GenericAttributesParam, dto); + + Assert.IsNotNull(result); + Assert.AreEqual(789, result.Id); + Assert.AreEqual("Empty Attributes", result.Name); + Assert.IsNotNull(result.GenericAttributes); + Assert.AreEqual(0, result.GenericAttributes.Count); + } + + [TestMethod] + public async Task GenericAttributes_ManyItems_AllPreserved() + { + var dto = new TestDtoWithGenericAttributes + { + Id = 999, + Name = "Many Attributes", + GenericAttributes = Enumerable.Range(0, 50).Select(i => new TestGenericAttribute + { + Id = i, + Key = $"Attribute_{i}", + Value = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString() + }).ToList() + }; + + var result = await _client.PostDataAsync( + TestSignalRTags.GenericAttributesParam, dto); + + Assert.IsNotNull(result); + Assert.AreEqual(50, result.GenericAttributes.Count); + + for (int i = 0; i < 50; i++) + { + var expectedValue = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString(); + Assert.AreEqual($"Attribute_{i}", result.GenericAttributes[i].Key); + Assert.AreEqual(expectedValue, result.GenericAttributes[i].Value); + } + } + + [TestMethod] + public async Task StringInterning_ShortStrings_DoNotShiftInternTableIndices() + { + var dto = new TestDtoWithGenericAttributes + { + Id = 1, + Name = "ProductName", + GenericAttributes = + [ + new TestGenericAttribute { Id = 1, Key = "A", Value = "0" }, + new TestGenericAttribute { Id = 2, Key = "B", Value = "1" }, + new TestGenericAttribute { Id = 3, Key = "LongKey1", Value = "LongValue1" }, + new TestGenericAttribute { Id = 4, Key = "C", Value = "2" }, + new TestGenericAttribute { Id = 5, Key = "LongKey2", Value = "LongValue2" }, + ] + }; + + var result = await _client.PostDataAsync( + TestSignalRTags.GenericAttributesParam, dto); + + Assert.IsNotNull(result); + Assert.AreEqual("ProductName", result.Name); + Assert.AreEqual(5, result.GenericAttributes.Count); + + Assert.AreEqual("A", result.GenericAttributes[0].Key); + Assert.AreEqual("0", result.GenericAttributes[0].Value); + + Assert.AreEqual("B", result.GenericAttributes[1].Key); + Assert.AreEqual("1", result.GenericAttributes[1].Value); + + Assert.AreEqual("LongKey1", result.GenericAttributes[2].Key); + Assert.AreEqual("LongValue1", result.GenericAttributes[2].Value); + + Assert.AreEqual("C", result.GenericAttributes[3].Key); + Assert.AreEqual("2", result.GenericAttributes[3].Value); + + Assert.AreEqual("LongKey2", result.GenericAttributes[4].Key); + Assert.AreEqual("LongValue2", result.GenericAttributes[4].Value); + } + + [TestMethod] + public async Task StringInterning_BoundaryLength_HandledCorrectly() + { + var dto = new TestDtoWithGenericAttributes + { + Id = 1, + Name = "Test", + GenericAttributes = + [ + new TestGenericAttribute { Id = 1, Key = "abc", Value = "123" }, + new TestGenericAttribute { Id = 2, Key = "abcd", Value = "1234" }, + new TestGenericAttribute { Id = 3, Key = "ab", Value = "12" }, + new TestGenericAttribute { Id = 4, Key = "abcde", Value = "12345" }, + new TestGenericAttribute { Id = 5, Key = "a", Value = "1" }, + ] + }; + + var result = await _client.PostDataAsync( + TestSignalRTags.GenericAttributesParam, dto); + + Assert.IsNotNull(result); + Assert.AreEqual("Test", result.Name); + + Assert.AreEqual("abc", result.GenericAttributes[0].Key); + Assert.AreEqual("123", result.GenericAttributes[0].Value); + + Assert.AreEqual("abcd", result.GenericAttributes[1].Key); + Assert.AreEqual("1234", result.GenericAttributes[1].Value); + + Assert.AreEqual("ab", result.GenericAttributes[2].Key); + Assert.AreEqual("12", result.GenericAttributes[2].Value); + + Assert.AreEqual("abcde", result.GenericAttributes[3].Key); + Assert.AreEqual("12345", result.GenericAttributes[3].Value); + + Assert.AreEqual("a", result.GenericAttributes[4].Key); + Assert.AreEqual("1", result.GenericAttributes[4].Value); + } + + #endregion +} + +/// +/// Runs all SignalR tests with JSON serialization. +/// +[TestClass] +public class SignalRClientToHubTest_Json : SignalRClientToHubTestBase +{ + protected override AcSerializerType SerializerType => AcSerializerType.Json; +} + +/// +/// Runs all SignalR tests with Binary serialization. +/// +[TestClass] +public class SignalRClientToHubTest_Binary : SignalRClientToHubTestBase +{ + protected override AcSerializerType SerializerType => AcSerializerType.Binary; } \ No newline at end of file diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs index f9b078b..aba06cb 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs @@ -342,4 +342,20 @@ public class TestSignalRService2 #endregion + #region Binary Serialization with GenericAttributes Test + + /// + /// Tests Binary serialization with GenericAttributes containing string-stored DateTime values. + /// This reproduces the production bug scenario where DateTime values stored as strings + /// in GenericAttributes were incorrectly blamed for Binary serialization issues. + /// + [SignalR(TestSignalRTags.GenericAttributesParam)] + public TestDtoWithGenericAttributes HandleGenericAttributes(TestDtoWithGenericAttributes dto) + { + // Return the same DTO to verify Binary round-trip preserves all values + return dto; + } + + #endregion + } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs index c6a340d..7b54390 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs @@ -67,4 +67,7 @@ public abstract class TestSignalRTags : AcSignalRTags public const int TaskFromResultTestOrderItemParam = 211; public const int TaskFromResultIntParam = 212; public const int TaskFromResultNoParams = 213; + + // Binary serialization with GenericAttributes test + public const int GenericAttributesParam = 220; } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs index c97c114..0f17924 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs @@ -63,6 +63,17 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase(service)); } + /// + /// Sets the serializer type for testing Binary vs JSON serialization. + /// + public void SetSerializerType(AcSerializerType serializerType) + { + if (serializerType == AcSerializerType.Binary) + SerializerOptions = new AcBinarySerializerOptions(); + else + SerializerOptions = new AcJsonSerializerOptions(); + } + #endregion #region Overridden Context Accessors diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index 9de4543..4c1bb1b 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -511,17 +511,17 @@ namespace AyCode.Services.SignalRs /// /// Deserializes a MessagePack response to the appropriate message type (JSON or Binary). - /// First tries to deserialize as Binary, then falls back to JSON if that fails. + /// Uses DetectSerializerTypeFromBytes to determine the format of the ResponseData. /// protected virtual ISignalResponseMessage DeserializeResponseMessage(byte[] messageBytes) { - // Try Binary format first (SignalResponseBinaryMessage) + // 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) { - // Verify it's actually binary data by checking the format + // Use the existing utility to detect if ResponseData is Binary format if (DetectSerializerTypeFromBytes(binaryMsg.ResponseData) == AcSerializerType.Binary) { return binaryMsg; @@ -530,7 +530,7 @@ namespace AyCode.Services.SignalRs } catch { - // Not a binary message, try JSON + // Failed to deserialize as Binary message } // Fall back to JSON format