Fix Binary serialization of string DateTimes in attributes
Add regression tests for string-stored DateTime values in GenericAttribute-like collections, ensuring exact value preservation in Binary serialization. Fix AcBinaryDeserializer to only intern strings >= 4 chars, matching serializer logic and preventing intern table index mismatches. Refactor SignalR tests to run under both JSON and Binary modes, and add comprehensive tests for edge cases and production bug scenarios. Clean up test code and comments for clarity.
This commit is contained in:
parent
09a4604e52
commit
1a9e760b68
|
|
@ -0,0 +1,473 @@
|
|||
using AyCode.Core.Extensions;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DateTime serialization in Binary format.
|
||||
/// Covers edge cases like string-stored DateTime values in GenericAttribute-like scenarios.
|
||||
/// </summary>
|
||||
[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<DateTime>();
|
||||
|
||||
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<DateTime?>();
|
||||
|
||||
Assert.AreEqual(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullableDateTime_Null_RoundTrip_PreservesNull()
|
||||
{
|
||||
DateTime? original = null;
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(original);
|
||||
var result = binary.BinaryTo<DateTime?>();
|
||||
|
||||
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<TestObjectWithDateTime>();
|
||||
|
||||
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<TestObjectWithNullableDateTime>();
|
||||
|
||||
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<TestObjectWithNullableDateTime>();
|
||||
|
||||
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<TestGenericAttribute>();
|
||||
|
||||
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<TestGenericAttribute>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Key, result.Key);
|
||||
Assert.AreEqual("0", result.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GenericAttributeList_RoundTrip_PreservesAllValues()
|
||||
{
|
||||
var original = new List<TestGenericAttribute>
|
||||
{
|
||||
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<List<TestGenericAttribute>>();
|
||||
|
||||
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<TestDtoWithGenericAttributes>();
|
||||
|
||||
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<TestGenericAttribute>();
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var binaryResult = binary.BinaryTo<TestGenericAttribute>();
|
||||
|
||||
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<TestDtoWithGenericAttributes>();
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var binaryResult = binary.BinaryTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
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<TestGenericAttribute> GenericAttributes { get; set; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exact Production Scenario Test
|
||||
|
||||
/// <summary>
|
||||
/// 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<DateTime> fails to parse the string value
|
||||
/// </summary>
|
||||
[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<TestDtoWithGenericAttributes>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that verifies the exact bytes of the string are preserved.
|
||||
/// </summary>
|
||||
[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<string>();
|
||||
|
||||
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]}')");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with large list of GenericAttributes to catch any edge cases.
|
||||
/// </summary>
|
||||
[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<TestDtoWithGenericAttributes>();
|
||||
|
||||
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<DateTime> Simulation Tests
|
||||
|
||||
/// <summary>
|
||||
/// This test simulates what CommonHelper2.To<DateTime> does with various string values.
|
||||
/// It helps identify which values will cause the FormatException.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CommonHelperSimulation_ValidDateString_ParsesSuccessfully()
|
||||
{
|
||||
var dateString = "10/24/2025 00:27:00";
|
||||
|
||||
// This is what CommonHelper2.To<DateTime> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This test shows that "0" cannot be parsed as DateTime - this is the actual bug!
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test various invalid DateTime strings that might be stored in GenericAttributes.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
|
|
@ -284,6 +284,28 @@ public class TestGuidItem : IId<Guid>
|
|||
|
||||
#region Test-specific classes
|
||||
|
||||
/// <summary>
|
||||
/// Simulates NopCommerce GenericAttribute - stores key-value pairs where DateTime values
|
||||
/// are stored as strings in the database.
|
||||
/// </summary>
|
||||
public class TestGenericAttribute
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class TestDtoWithGenericAttributes : IId<int>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public List<TestGenericAttribute> GenericAttributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order with nullable collections for null vs empty testing
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -288,6 +288,9 @@ public static class AcBinaryDeserializer
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum string length for interning. Must match serializer's MinStringInternLength.
|
||||
/// Default: 4 (from AcBinarySerializerOptions)
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -7,16 +7,18 @@ using MessagePack.Resolvers;
|
|||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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"
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task GetAll_WithEmptyArrayContextParam_ReturnsResult()
|
||||
{
|
||||
var result = await _client.GetAllAsync<int[]>(TestSignalRTags.IntArrayParam, [Array.Empty<int>()]);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests GetAllAsync with non-empty array as context parameter.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task GetAll_WithArrayContextParam_ReturnsDoubledValues()
|
||||
{
|
||||
|
|
@ -504,7 +498,7 @@ public class SignalRClientToHubTest
|
|||
|
||||
#endregion
|
||||
|
||||
#region Async Task<T> Method Tests - Critical for detecting non-awaited Tasks
|
||||
#region Async Task<T> 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<TestOrder, TestOrder>(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' <brackets> & ampersand \\ backslash \n newline";
|
||||
|
||||
var result = await _client.PostDataAsync<string, string>(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<string, string>(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<string, string>(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<int, string>(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<TestOrderItem>
|
||||
{
|
||||
|
|
@ -690,16 +674,6 @@ public class SignalRClientToHubTest
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_String_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
|
|
@ -743,14 +703,6 @@ public class SignalRClientToHubTest
|
|||
"Should return the actual string, not Task wrapper JSON");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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}
|
||||
/// </summary>
|
||||
[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).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests Task.FromResult<int> - primitive value type via Task.FromResult.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_Int_ReturnsActualResult_NotTaskWrapper()
|
||||
{
|
||||
var result = await _client.PostDataAsync<int, int>(TestSignalRTags.TaskFromResultIntParam, 42);
|
||||
|
||||
Assert.AreEqual(84, result,
|
||||
"Should return doubled value (42*2=84). " +
|
||||
"If 0 or wrong value, Task.FromResult<int> was not properly unwrapped.");
|
||||
Assert.AreEqual(84, result, "Should return doubled value (42*2=84).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests Task.CompletedTask (non-generic Task) via SignalR.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public async Task TaskFromResult_NoResult_CompletesSuccessfully()
|
||||
{
|
||||
// This should not throw and should complete successfully
|
||||
var result = await _client.GetAllAsync<object>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword.
|
||||
/// This was the root cause of the production bug where Task wrapper was serialized.
|
||||
/// </summary>
|
||||
[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<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
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<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
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<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
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<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
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<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all SignalR tests with JSON serialization.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SignalRClientToHubTest_Json : SignalRClientToHubTestBase
|
||||
{
|
||||
protected override AcSerializerType SerializerType => AcSerializerType.Json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all SignalR tests with Binary serialization.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SignalRClientToHubTest_Binary : SignalRClientToHubTestBase
|
||||
{
|
||||
protected override AcSerializerType SerializerType => AcSerializerType.Binary;
|
||||
}
|
||||
|
|
@ -342,4 +342,20 @@ public class TestSignalRService2
|
|||
|
||||
#endregion
|
||||
|
||||
#region Binary Serialization with GenericAttributes Test
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.GenericAttributesParam)]
|
||||
public TestDtoWithGenericAttributes HandleGenericAttributes(TestDtoWithGenericAttributes dto)
|
||||
{
|
||||
// Return the same DTO to verify Binary round-trip preserves all values
|
||||
return dto;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogg
|
|||
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the serializer type for testing Binary vs JSON serialization.
|
||||
/// </summary>
|
||||
public void SetSerializerType(AcSerializerType serializerType)
|
||||
{
|
||||
if (serializerType == AcSerializerType.Binary)
|
||||
SerializerOptions = new AcBinarySerializerOptions();
|
||||
else
|
||||
SerializerOptions = new AcJsonSerializerOptions();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overridden Context Accessors
|
||||
|
|
|
|||
|
|
@ -511,17 +511,17 @@ namespace AyCode.Services.SignalRs
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SignalResponseBinaryMessage>(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
|
||||
|
|
|
|||
Loading…
Reference in New Issue