Fix binary deserializer string interning and add regressions
- Fix: Ensure property names and skipped strings are interned during binary deserialization, preventing interned string index mismatches. - Add: Extensive regression tests for string interning edge cases, including property mismatch (server has more properties than client), deeply nested objects, repeated/unique strings, and empty/null handling. - Add: New test DTOs and infrastructure for property mismatch scenarios. - Update: SignalR test helpers and services to support both JSON and Binary serialization in all tests. - Improve: SignalRRequestModelPool now initializes models on Get(). - These changes address production bugs and ensure robust, consistent string interning and property skipping in AcBinarySerializer.
This commit is contained in:
parent
1a9e760b68
commit
6faed09f9f
|
|
@ -303,59 +303,321 @@ public class AcBinarySerializerTests
|
||||||
Assert.AreEqual(obj.Field1, result2!.Field1);
|
Assert.AreEqual(obj.Field1, result2!.Field1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
/// <summary>
|
||||||
|
/// REGRESSION TEST: Comprehensive string interning edge cases.
|
||||||
#region Size Comparison Tests
|
///
|
||||||
|
/// Production bug pattern: "Invalid interned string index: X. Interned strings count: Y"
|
||||||
|
///
|
||||||
|
/// Root causes identified:
|
||||||
|
/// 1. Property names not being registered in intern table during deserialization
|
||||||
|
/// 2. String values with same length but different content
|
||||||
|
/// 3. Nested objects creating complex interning order
|
||||||
|
/// 4. Collections of objects with repeated property names
|
||||||
|
/// </summary>
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void Serialize_IsSmallerThanJson()
|
public void StringInterning_PropertyNames_MustBeRegisteredDuringDeserialization()
|
||||||
{
|
{
|
||||||
var obj = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3);
|
// This test verifies that property names (>= 4 chars) are properly
|
||||||
|
// registered in the intern table during deserialization.
|
||||||
|
// The serializer registers them via WriteString, so deserializer must too.
|
||||||
|
|
||||||
var jsonBytes = System.Text.Encoding.UTF8.GetBytes(obj.ToJson());
|
var items = Enumerable.Range(0, 10).Select(i => new TestClassWithLongPropertyNames
|
||||||
var binaryBytes = obj.ToBinary();
|
{
|
||||||
|
FirstProperty = $"Value1_{i}",
|
||||||
|
SecondProperty = $"Value2_{i}",
|
||||||
|
ThirdProperty = $"Value3_{i}",
|
||||||
|
FourthProperty = $"Value4_{i}"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
Console.WriteLine($"JSON size: {jsonBytes.Length} bytes");
|
var binary = items.ToBinary();
|
||||||
Console.WriteLine($"Binary size: {binaryBytes.Length} bytes");
|
var result = binary.BinaryTo<List<TestClassWithLongPropertyNames>>();
|
||||||
Console.WriteLine($"Ratio: {(double)binaryBytes.Length / jsonBytes.Length:P2}");
|
|
||||||
|
|
||||||
Assert.IsTrue(binaryBytes.Length < jsonBytes.Length,
|
|
||||||
$"Binary ({binaryBytes.Length}) should be smaller than JSON ({jsonBytes.Length})");
|
|
||||||
|
|
||||||
// Verify roundtrip works
|
|
||||||
var result = binaryBytes.BinaryTo<TestOrder>();
|
|
||||||
Assert.IsNotNull(result);
|
Assert.IsNotNull(result);
|
||||||
Assert.AreEqual(obj.Id, result.Id);
|
Assert.AreEqual(10, result.Count);
|
||||||
Assert.AreEqual(obj.Items.Count, result.Items.Count);
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual($"Value1_{i}", result[i].FirstProperty);
|
||||||
|
Assert.AreEqual($"Value2_{i}", result[i].SecondProperty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
[TestMethod]
|
||||||
|
public void StringInterning_MixedShortAndLongStrings_HandledCorrectly()
|
||||||
|
{
|
||||||
|
// Short strings (< 4 chars) are NOT interned
|
||||||
|
// Long strings (>= 4 chars) ARE interned
|
||||||
|
// This creates different traversal patterns
|
||||||
|
|
||||||
#region Extension Method Tests
|
var items = Enumerable.Range(0, 20).Select(i => new TestClassWithMixedStrings
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
ShortName = $"A{i % 3}", // 2-3 chars, NOT interned
|
||||||
|
LongName = $"LongName_{i % 5}", // > 4 chars, interned
|
||||||
|
Description = $"Description_value_{i % 7}", // > 4 chars, interned
|
||||||
|
Tag = i % 2 == 0 ? "AB" : "XY" // 2 chars, NOT interned
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var binary = items.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestClassWithMixedStrings>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual(20, result.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(i, result[i].Id);
|
||||||
|
Assert.AreEqual($"A{i % 3}", result[i].ShortName);
|
||||||
|
Assert.AreEqual($"LongName_{i % 5}", result[i].LongName);
|
||||||
|
Assert.AreEqual($"Description_value_{i % 7}", result[i].Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void BinaryCloneTo_CreatesDeepCopy()
|
public void StringInterning_DeeplyNestedObjects_MaintainsCorrectOrder()
|
||||||
{
|
{
|
||||||
var original = new TestNestedClass
|
// Complex nested structure where property names and values
|
||||||
|
// are interleaved in a specific order
|
||||||
|
|
||||||
|
var root = new TestNestedStructure
|
||||||
{
|
{
|
||||||
Id = 1,
|
RootName = "RootObject",
|
||||||
Name = "Original",
|
Level1Items = Enumerable.Range(0, 5).Select(i => new TestLevel1
|
||||||
Child = new TestSimpleClass { Id = 2, Name = "Child" }
|
{
|
||||||
|
Level1Name = $"Level1_{i}",
|
||||||
|
Level2Items = Enumerable.Range(0, 3).Select(j => new TestLevel2
|
||||||
|
{
|
||||||
|
Level2Name = $"Level2_{i}_{j}",
|
||||||
|
Value = $"Value_{i * 3 + j}"
|
||||||
|
}).ToList()
|
||||||
|
}).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
var clone = original.BinaryCloneTo();
|
var binary = root.ToBinary();
|
||||||
|
var result = binary.BinaryTo<TestNestedStructure>();
|
||||||
|
|
||||||
Assert.IsNotNull(clone);
|
Assert.IsNotNull(result);
|
||||||
Assert.AreNotSame(original, clone);
|
Assert.AreEqual("RootObject", result.RootName);
|
||||||
Assert.AreNotSame(original.Child, clone.Child);
|
Assert.AreEqual(5, result.Level1Items.Count);
|
||||||
Assert.AreEqual(original.Id, clone.Id);
|
|
||||||
Assert.AreEqual(original.Child.Id, clone.Child!.Id);
|
|
||||||
|
|
||||||
// Modify clone, original should be unchanged
|
for (int i = 0; i < 5; i++)
|
||||||
clone.Id = 999;
|
{
|
||||||
clone.Child.Id = 888;
|
Assert.AreEqual($"Level1_{i}", result.Level1Items[i].Level1Name);
|
||||||
Assert.AreEqual(1, original.Id);
|
Assert.AreEqual(3, result.Level1Items[i].Level2Items.Count);
|
||||||
Assert.AreEqual(2, original.Child.Id);
|
|
||||||
|
for (int j = 0; j < 3; j++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual($"Level2_{i}_{j}", result.Level1Items[i].Level2Items[j].Level2Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StringInterning_RepeatedPropertyValuesAcrossObjects_UsesReferences()
|
||||||
|
{
|
||||||
|
// When the same string value appears multiple times,
|
||||||
|
// the serializer writes StringInterned reference instead of the full string.
|
||||||
|
// The deserializer must look up the correct index.
|
||||||
|
|
||||||
|
var items = Enumerable.Range(0, 50).Select(i => new TestClassWithRepeatedValues
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
|
||||||
|
Category = i % 5 == 0 ? "CategoryA" : i % 5 == 1 ? "CategoryB" : "CategoryC",
|
||||||
|
Priority = i % 2 == 0 ? "High" : "Low_Priority_Value"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var binary = items.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestClassWithRepeatedValues>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual(50, result.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(i, result[i].Id);
|
||||||
|
var expectedStatus = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed";
|
||||||
|
Assert.AreEqual(expectedStatus, result[i].Status, $"Status mismatch at index {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StringInterning_ManyUniqueStringsFollowedByRepeats_CorrectIndexLookup()
|
||||||
|
{
|
||||||
|
// First create many unique strings (all get registered)
|
||||||
|
// Then repeat some of them (use StringInterned references)
|
||||||
|
// This tests the index calculation
|
||||||
|
|
||||||
|
var items = new List<TestClassWithNameValue>();
|
||||||
|
|
||||||
|
// First 30 items with unique names (all registered as new)
|
||||||
|
for (int i = 0; i < 30; i++)
|
||||||
|
{
|
||||||
|
items.Add(new TestClassWithNameValue
|
||||||
|
{
|
||||||
|
Name = $"UniqueName_{i:D4}",
|
||||||
|
Value = $"UniqueValue_{i:D4}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next 20 items reuse names from first batch (should use StringInterned)
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
items.Add(new TestClassWithNameValue
|
||||||
|
{
|
||||||
|
Name = $"UniqueName_{i % 10:D4}", // Reuse first 10 names
|
||||||
|
Value = $"UniqueValue_{(i + 10) % 30:D4}" // Reuse different values
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var binary = items.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestClassWithNameValue>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual(50, result.Count);
|
||||||
|
|
||||||
|
// Verify first batch
|
||||||
|
for (int i = 0; i < 30; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual($"UniqueName_{i:D4}", result[i].Name, $"Name mismatch at index {i}");
|
||||||
|
Assert.AreEqual($"UniqueValue_{i:D4}", result[i].Value, $"Value mismatch at index {i}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify second batch (reused strings)
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual($"UniqueName_{i % 10:D4}", result[30 + i].Name, $"Name mismatch at index {30 + i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StringInterning_EmptyAndNullStrings_DoNotAffectInternTable()
|
||||||
|
{
|
||||||
|
// Empty strings use StringEmpty type code
|
||||||
|
// Null strings use Null type code
|
||||||
|
// Neither should affect intern table indices
|
||||||
|
|
||||||
|
var items = new List<TestClassWithNullableStrings>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 25; i++)
|
||||||
|
{
|
||||||
|
items.Add(new TestClassWithNullableStrings
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
RequiredName = $"Required_{i:D3}",
|
||||||
|
OptionalName = i % 3 == 0 ? null : i % 3 == 1 ? "" : $"Optional_{i:D3}",
|
||||||
|
Description = i % 2 == 0 ? $"Description_{i % 5:D3}" : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var binary = items.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestClassWithNullableStrings>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result);
|
||||||
|
Assert.AreEqual(25, result.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < 25; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(i, result[i].Id);
|
||||||
|
Assert.AreEqual($"Required_{i:D3}", result[i].RequiredName);
|
||||||
|
|
||||||
|
if (i % 3 == 0)
|
||||||
|
{
|
||||||
|
Assert.IsNull(result[i].OptionalName, $"OptionalName at index {i} should be null");
|
||||||
|
}
|
||||||
|
else if (i % 3 == 1)
|
||||||
|
{
|
||||||
|
// Empty string may deserialize as either "" or null depending on implementation
|
||||||
|
Assert.IsTrue(string.IsNullOrEmpty(result[i].OptionalName),
|
||||||
|
$"OptionalName at index {i} should be null or empty, got: '{result[i].OptionalName}'");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.AreEqual($"Optional_{i:D3}", result[i].OptionalName,
|
||||||
|
$"OptionalName at index {i} mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StringInterning_ProductionLikeCustomerDto_RoundTrip()
|
||||||
|
{
|
||||||
|
// Simulate the CustomerDto structure that causes production issues
|
||||||
|
// Key characteristics:
|
||||||
|
// - Many string properties (FirstName, LastName, Email, Company, etc.)
|
||||||
|
// - GenericAttributes list with repeated Key values
|
||||||
|
// - List of items with common status/category values
|
||||||
|
|
||||||
|
var customers = Enumerable.Range(0, 25).Select(i => new TestCustomerLikeDto
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
FirstName = $"FirstName_{i % 10}", // 10 unique values
|
||||||
|
LastName = $"LastName_{i % 8}", // 8 unique values
|
||||||
|
Email = $"user{i}@example.com", // All unique
|
||||||
|
Company = $"Company_{i % 5}", // 5 unique values
|
||||||
|
Department = i % 3 == 0 ? "Sales" : i % 3 == 1 ? "Engineering" : "Marketing",
|
||||||
|
Role = i % 4 == 0 ? "Admin" : i % 4 == 1 ? "User" : i % 4 == 2 ? "Manager" : "Guest",
|
||||||
|
Status = i % 2 == 0 ? "Active" : "Inactive",
|
||||||
|
Attributes = new List<TestGenericAttribute>
|
||||||
|
{
|
||||||
|
new() { Key = "DateOfReceipt", Value = $"{i % 28 + 1}/24/2025 00:27:00" },
|
||||||
|
new() { Key = "Priority", Value = (i % 5).ToString() },
|
||||||
|
new() { Key = "CustomField1", Value = $"CustomValue_{i % 7}" },
|
||||||
|
new() { Key = "CustomField2", Value = i % 2 == 0 ? "Yes" : "No_Value" }
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var binary = customers.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestCustomerLikeDto>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result, "Result should not be null - deserialization failed");
|
||||||
|
Assert.AreEqual(25, result.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < 25; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(i, result[i].Id, $"Id mismatch at index {i}");
|
||||||
|
Assert.AreEqual($"FirstName_{i % 10}", result[i].FirstName, $"FirstName mismatch at index {i}");
|
||||||
|
Assert.AreEqual($"LastName_{i % 8}", result[i].LastName, $"LastName mismatch at index {i}");
|
||||||
|
Assert.AreEqual($"user{i}@example.com", result[i].Email, $"Email mismatch at index {i}");
|
||||||
|
Assert.AreEqual(4, result[i].Attributes.Count, $"Attributes count mismatch at index {i}");
|
||||||
|
|
||||||
|
Assert.AreEqual("DateOfReceipt", result[i].Attributes[0].Key);
|
||||||
|
Assert.AreEqual("Priority", result[i].Attributes[1].Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StringInterning_LargeListWithHighStringReuse_HandlesCorrectly()
|
||||||
|
{
|
||||||
|
// Large dataset (100+ items) with high string reuse ratio
|
||||||
|
// This is the scenario that triggers production bugs
|
||||||
|
|
||||||
|
const int itemCount = 150;
|
||||||
|
var items = Enumerable.Range(0, itemCount).Select(i => new TestHighReuseDto
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
// Property names are reused 150 times (once per object)
|
||||||
|
CategoryCode = $"CAT_{i % 10:D2}", // 10 unique values, 15x reuse each
|
||||||
|
StatusCode = $"STATUS_{i % 5:D2}", // 5 unique values, 30x reuse each
|
||||||
|
TypeCode = $"TYPE_{i % 3:D2}", // 3 unique values, 50x reuse each
|
||||||
|
PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW", // 2 values, 75x each
|
||||||
|
UniqueField = $"UNIQUE_{i:D4}" // All unique, no reuse
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var binary = items.ToBinary();
|
||||||
|
var result = binary.BinaryTo<List<TestHighReuseDto>>();
|
||||||
|
|
||||||
|
Assert.IsNotNull(result, "Result should not be null");
|
||||||
|
Assert.AreEqual(itemCount, result.Count, $"Expected {itemCount} items");
|
||||||
|
|
||||||
|
// Verify every item
|
||||||
|
for (int i = 0; i < itemCount; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(i, result[i].Id, $"Id mismatch at {i}");
|
||||||
|
Assert.AreEqual($"CAT_{i % 10:D2}", result[i].CategoryCode, $"CategoryCode mismatch at {i}");
|
||||||
|
Assert.AreEqual($"STATUS_{i % 5:D2}", result[i].StatusCode, $"StatusCode mismatch at {i}");
|
||||||
|
Assert.AreEqual($"TYPE_{i % 3:D2}", result[i].TypeCode, $"TypeCode mismatch at {i}");
|
||||||
|
Assert.AreEqual($"UNIQUE_{i:D4}", result[i].UniqueField, $"UniqueField mismatch at {i}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
@ -391,6 +653,94 @@ public class AcBinarySerializerTests
|
||||||
public string Field4 { get; set; } = "";
|
public string Field4 { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New test models for string interning edge cases
|
||||||
|
|
||||||
|
private class TestClassWithLongPropertyNames
|
||||||
|
{
|
||||||
|
public string FirstProperty { get; set; } = "";
|
||||||
|
public string SecondProperty { get; set; } = "";
|
||||||
|
public string ThirdProperty { get; set; } = "";
|
||||||
|
public string FourthProperty { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestClassWithMixedStrings
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ShortName { get; set; } = ""; // < 4 chars
|
||||||
|
public string LongName { get; set; } = ""; // >= 4 chars
|
||||||
|
public string Description { get; set; } = ""; // >= 4 chars
|
||||||
|
public string Tag { get; set; } = ""; // < 4 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestNestedStructure
|
||||||
|
{
|
||||||
|
public string RootName { get; set; } = "";
|
||||||
|
public List<TestLevel1> Level1Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestLevel1
|
||||||
|
{
|
||||||
|
public string Level1Name { get; set; } = "";
|
||||||
|
public List<TestLevel2> Level2Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestLevel2
|
||||||
|
{
|
||||||
|
public string Level2Name { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestClassWithRepeatedValues
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Priority { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestClassWithNameValue
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestClassWithNullableStrings
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string RequiredName { get; set; } = "";
|
||||||
|
public string? OptionalName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestCustomerLikeDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string LastName { get; set; } = "";
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Company { get; set; } = "";
|
||||||
|
public string Department { get; set; } = "";
|
||||||
|
public string Role { get; set; } = "";
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public List<TestGenericAttribute> Attributes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestGenericAttribute
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestHighReuseDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string CategoryCode { get; set; } = "";
|
||||||
|
public string StatusCode { get; set; } = "";
|
||||||
|
public string TypeCode { get; set; } = "";
|
||||||
|
public string PriorityCode { get; set; } = "";
|
||||||
|
public string UniqueField { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Benchmark Order Tests
|
#region Benchmark Order Tests
|
||||||
|
|
|
||||||
|
|
@ -378,3 +378,83 @@ public class ObjectWithNullItems
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Property Mismatch DTOs (Server has more properties than Client)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Server-side" DTO with extra properties that the "client" doesn't know about.
|
||||||
|
/// Used to test SkipValue functionality when deserializing unknown properties.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerCustomerDto : IId<int>
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// Common properties (both server and client have these)
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string LastName { get; set; } = "";
|
||||||
|
|
||||||
|
// Extra properties (only server has these - client will skip them)
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Phone { get; set; } = "";
|
||||||
|
public string Address { get; set; } = "";
|
||||||
|
public string City { get; set; } = "";
|
||||||
|
public string Country { get; set; } = "";
|
||||||
|
public string PostalCode { get; set; } = "";
|
||||||
|
public string Company { get; set; } = "";
|
||||||
|
public string Department { get; set; } = "";
|
||||||
|
public string Notes { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public TestStatus Status { get; set; } = TestStatus.Active;
|
||||||
|
public bool IsVerified { get; set; }
|
||||||
|
public int LoginCount { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Client-side" DTO with fewer properties than the server version.
|
||||||
|
/// When deserializing ServerCustomerDto data into this type,
|
||||||
|
/// the deserializer must skip unknown properties correctly
|
||||||
|
/// while still maintaining string intern table consistency.
|
||||||
|
/// </summary>
|
||||||
|
public class ClientCustomerDto : IId<int>
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// Only basic properties - client doesn't need all server fields
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string LastName { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server DTO with nested objects that client doesn't know about.
|
||||||
|
/// Tests skipping complex nested structures.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerOrderWithExtras : IId<int>
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OrderNumber { get; set; } = "";
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
// Nested object that client doesn't have
|
||||||
|
public ServerCustomerDto? Customer { get; set; }
|
||||||
|
|
||||||
|
// List of objects that client doesn't know about
|
||||||
|
public List<ServerCustomerDto> RelatedCustomers { get; set; } = [];
|
||||||
|
|
||||||
|
// Extra simple properties
|
||||||
|
public string InternalNotes { get; set; } = "";
|
||||||
|
public string ProcessingCode { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client version of the order - doesn't have Customer/RelatedCustomers properties.
|
||||||
|
/// </summary>
|
||||||
|
public class ClientOrderSimple : IId<int>
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OrderNumber { get; set; } = "";
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -454,7 +454,15 @@ public static class AcBinaryDeserializer
|
||||||
var typeCode = context.ReadByte();
|
var typeCode = context.ReadByte();
|
||||||
if (typeCode == BinaryTypeCode.String)
|
if (typeCode == BinaryTypeCode.String)
|
||||||
{
|
{
|
||||||
propertyName = ReadStringValue(ref context);
|
// CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
|
||||||
|
// The serializer's WriteString registers property names in the intern table,
|
||||||
|
// so we must do the same during deserialization to maintain index consistency.
|
||||||
|
propertyName = ReadAndInternString(ref context);
|
||||||
|
}
|
||||||
|
else if (typeCode == BinaryTypeCode.StringInterned)
|
||||||
|
{
|
||||||
|
// Property name was previously interned, look it up
|
||||||
|
propertyName = context.GetInternedString((int)context.ReadVarUInt());
|
||||||
}
|
}
|
||||||
else if (typeCode == BinaryTypeCode.StringEmpty)
|
else if (typeCode == BinaryTypeCode.StringEmpty)
|
||||||
{
|
{
|
||||||
|
|
@ -510,7 +518,15 @@ public static class AcBinaryDeserializer
|
||||||
var typeCode = context.ReadByte();
|
var typeCode = context.ReadByte();
|
||||||
if (typeCode == BinaryTypeCode.String)
|
if (typeCode == BinaryTypeCode.String)
|
||||||
{
|
{
|
||||||
propertyName = ReadStringValue(ref context);
|
// CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
|
||||||
|
// The serializer's WriteString registers property names in the intern table,
|
||||||
|
// so we must do the same during deserialization to maintain index consistency.
|
||||||
|
propertyName = ReadAndInternString(ref context);
|
||||||
|
}
|
||||||
|
else if (typeCode == BinaryTypeCode.StringInterned)
|
||||||
|
{
|
||||||
|
// Property name was previously interned, look it up
|
||||||
|
propertyName = context.GetInternedString((int)context.ReadVarUInt());
|
||||||
}
|
}
|
||||||
else if (typeCode == BinaryTypeCode.StringEmpty)
|
else if (typeCode == BinaryTypeCode.StringEmpty)
|
||||||
{
|
{
|
||||||
|
|
@ -844,8 +860,9 @@ public static class AcBinaryDeserializer
|
||||||
context.Skip(16);
|
context.Skip(16);
|
||||||
return;
|
return;
|
||||||
case BinaryTypeCode.String:
|
case BinaryTypeCode.String:
|
||||||
var strLen = (int)context.ReadVarUInt();
|
// CRITICAL FIX: Must register string in intern table even when skipping!
|
||||||
context.Skip(strLen);
|
// The serializer registered this string, so we must too to keep indices in sync.
|
||||||
|
SkipAndInternString(ref context);
|
||||||
return;
|
return;
|
||||||
case BinaryTypeCode.StringInterned:
|
case BinaryTypeCode.StringInterned:
|
||||||
context.ReadVarUInt();
|
context.ReadVarUInt();
|
||||||
|
|
@ -874,6 +891,24 @@ public static class AcBinaryDeserializer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skip a string but still register it in the intern table if it meets the length threshold.
|
||||||
|
/// This is critical for maintaining index consistency with the serializer.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static void SkipAndInternString(ref BinaryDeserializationContext context)
|
||||||
|
{
|
||||||
|
var byteLen = (int)context.ReadVarUInt();
|
||||||
|
if (byteLen == 0) return;
|
||||||
|
|
||||||
|
// Read the string to check its char length for interning
|
||||||
|
var str = context.ReadString(byteLen);
|
||||||
|
if (str.Length >= context.MinStringInternLength)
|
||||||
|
{
|
||||||
|
context.RegisterInternedString(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void SkipObject(ref BinaryDeserializationContext context)
|
private static void SkipObject(ref BinaryDeserializationContext context)
|
||||||
{
|
{
|
||||||
// Skip ref ID if present
|
// Skip ref ID if present
|
||||||
|
|
@ -885,7 +920,7 @@ public static class AcBinaryDeserializer
|
||||||
var propCount = (int)context.ReadVarUInt();
|
var propCount = (int)context.ReadVarUInt();
|
||||||
for (int i = 0; i < propCount; i++)
|
for (int i = 0; i < propCount; i++)
|
||||||
{
|
{
|
||||||
// Skip property name
|
// Skip property name - but must register in intern table!
|
||||||
if (context.HasMetadata)
|
if (context.HasMetadata)
|
||||||
{
|
{
|
||||||
context.ReadVarUInt();
|
context.ReadVarUInt();
|
||||||
|
|
@ -895,9 +930,15 @@ public static class AcBinaryDeserializer
|
||||||
var nameCode = context.ReadByte();
|
var nameCode = context.ReadByte();
|
||||||
if (nameCode == BinaryTypeCode.String)
|
if (nameCode == BinaryTypeCode.String)
|
||||||
{
|
{
|
||||||
var len = (int)context.ReadVarUInt();
|
// CRITICAL FIX: Must register property name in intern table even when skipping!
|
||||||
context.Skip(len);
|
SkipAndInternString(ref context);
|
||||||
}
|
}
|
||||||
|
else if (nameCode == BinaryTypeCode.StringInterned)
|
||||||
|
{
|
||||||
|
// Just read the index, no registration needed
|
||||||
|
context.ReadVarUInt();
|
||||||
|
}
|
||||||
|
// StringEmpty doesn't need any action
|
||||||
}
|
}
|
||||||
// Skip value
|
// Skip value
|
||||||
SkipValue(ref context);
|
SkipValue(ref context);
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,24 @@ using MessagePack;
|
||||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Base class for ProcessOnReceiveMessage tests.
|
||||||
/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage.
|
/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage.
|
||||||
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
||||||
|
/// Derived classes specify the serializer type (JSON or Binary).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestClass]
|
public abstract class ProcessOnReceiveMessageTestsBase
|
||||||
public class ProcessOnReceiveMessageTests
|
|
||||||
{
|
{
|
||||||
private TestableSignalRHub _hub = null!;
|
protected abstract AcSerializerType SerializerType { get; }
|
||||||
private TestSignalRService _service = null!;
|
|
||||||
|
protected TestableSignalRHub _hub = null!;
|
||||||
|
protected TestSignalRService _service = null!;
|
||||||
|
|
||||||
[TestInitialize]
|
[TestInitialize]
|
||||||
public void Setup()
|
public void Setup()
|
||||||
{
|
{
|
||||||
_hub = new TestableSignalRHub();
|
_hub = new TestableSignalRHub();
|
||||||
_service = new TestSignalRService();
|
_service = new TestSignalRService();
|
||||||
|
_hub.SetSerializerType(SerializerType);
|
||||||
_hub.RegisterService(_service);
|
_hub.RegisterService(_service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1043,3 +1047,21 @@ public class ProcessOnReceiveMessageTests
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs all ProcessOnReceiveMessage tests with JSON serialization.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class ProcessOnReceiveMessageTests_Json : ProcessOnReceiveMessageTestsBase
|
||||||
|
{
|
||||||
|
protected override AcSerializerType SerializerType => AcSerializerType.Json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs all ProcessOnReceiveMessage tests with Binary serialization.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class ProcessOnReceiveMessageTests_Binary : ProcessOnReceiveMessageTestsBase
|
||||||
|
{
|
||||||
|
protected override AcSerializerType SerializerType => AcSerializerType.Binary;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -924,83 +924,188 @@ public abstract class SignalRClientToHubTestBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Property Mismatch Tests (Server has more properties than Client - tests SkipValue)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REGRESSION TEST: Tests the case where server sends a DTO with more properties than the client knows about.
|
||||||
|
/// Bug: "Invalid interned string index: 15. Interned strings count: 12"
|
||||||
|
/// Root cause: When deserializing, unknown properties are skipped via SkipValue(), but the skipped
|
||||||
|
/// string values were not being registered in the intern table, causing index mismatch for later StringInterned references.
|
||||||
|
///
|
||||||
|
/// This test simulates the production bug where CustomerDto had properties on server
|
||||||
|
/// that the client didn't have defined.
|
||||||
|
/// </summary>
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task StringInterning_ShortStrings_DoNotShiftInternTableIndices()
|
public async Task PropertyMismatch_ServerHasMoreProperties_DeserializesCorrectly()
|
||||||
{
|
{
|
||||||
var dto = new TestDtoWithGenericAttributes
|
// Arrange: Create "server" DTO with many properties
|
||||||
|
var serverDto = new ServerCustomerDto
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Name = "ProductName",
|
FirstName = "John",
|
||||||
GenericAttributes =
|
LastName = "Smith",
|
||||||
[
|
Email = "john.smith@example.com",
|
||||||
new TestGenericAttribute { Id = 1, Key = "A", Value = "0" },
|
Phone = "+1-555-1234",
|
||||||
new TestGenericAttribute { Id = 2, Key = "B", Value = "1" },
|
Address = "123 Main Street",
|
||||||
new TestGenericAttribute { Id = 3, Key = "LongKey1", Value = "LongValue1" },
|
City = "New York",
|
||||||
new TestGenericAttribute { Id = 4, Key = "C", Value = "2" },
|
Country = "USA",
|
||||||
new TestGenericAttribute { Id = 5, Key = "LongKey2", Value = "LongValue2" },
|
PostalCode = "10001",
|
||||||
]
|
Company = "Acme Corp",
|
||||||
|
Department = "Engineering",
|
||||||
|
Notes = "VIP customer with special requirements",
|
||||||
|
Status = TestStatus.Active,
|
||||||
|
IsVerified = true,
|
||||||
|
LoginCount = 42,
|
||||||
|
Balance = 1234.56m
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _client.PostDataAsync<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
// Act: Send server DTO, receive client DTO (fewer properties)
|
||||||
TestSignalRTags.GenericAttributesParam, dto);
|
// This simulates the real bug scenario
|
||||||
|
var result = await _client.PostDataAsync<ServerCustomerDto, ClientCustomerDto>(
|
||||||
|
TestSignalRTags.PropertyMismatchParam, serverDto);
|
||||||
|
|
||||||
Assert.IsNotNull(result);
|
// Assert: Client should receive only the properties it knows about
|
||||||
Assert.AreEqual("ProductName", result.Name);
|
Assert.IsNotNull(result, "Result should not be null - deserialization should succeed even with unknown properties");
|
||||||
Assert.AreEqual(5, result.GenericAttributes.Count);
|
Assert.AreEqual(1, result.Id);
|
||||||
|
Assert.AreEqual("John", result.FirstName);
|
||||||
Assert.AreEqual("A", result.GenericAttributes[0].Key);
|
Assert.AreEqual("Smith", result.LastName);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REGRESSION TEST: Tests a list of DTOs with property mismatch.
|
||||||
|
/// This more closely simulates the production bug with GetMeasuringUsers returning List<CustomerDto>.
|
||||||
|
/// </summary>
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task StringInterning_BoundaryLength_HandledCorrectly()
|
public async Task PropertyMismatch_ListOfDtos_WithManyProperties_DeserializesCorrectly()
|
||||||
{
|
{
|
||||||
var dto = new TestDtoWithGenericAttributes
|
// Arrange: Create list of "server" DTOs with many string properties
|
||||||
|
var serverDtos = Enumerable.Range(0, 25).Select(i => new ServerCustomerDto
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = i,
|
||||||
Name = "Test",
|
FirstName = $"FirstName_{i % 10}", // 10 unique values (will be interned)
|
||||||
GenericAttributes =
|
LastName = $"LastName_{i % 8}", // 8 unique values
|
||||||
|
Email = $"user{i}@example.com",
|
||||||
|
Phone = $"+1-555-{i:D4}",
|
||||||
|
Address = $"Address_{i % 5}", // 5 unique values
|
||||||
|
City = i % 3 == 0 ? "New York" : i % 3 == 1 ? "Los Angeles" : "Chicago",
|
||||||
|
Country = "USA",
|
||||||
|
PostalCode = $"{10000 + i}",
|
||||||
|
Company = $"Company_{i % 6}", // 6 unique values
|
||||||
|
Department = i % 4 == 0 ? "Engineering" : i % 4 == 1 ? "Sales" : i % 4 == 2 ? "Marketing" : "Support",
|
||||||
|
Notes = $"Notes for customer {i}",
|
||||||
|
Status = (TestStatus)(i % 5),
|
||||||
|
IsVerified = i % 2 == 0,
|
||||||
|
LoginCount = i * 10,
|
||||||
|
Balance = i * 100.50m
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Act: Send list of server DTOs, receive list of client DTOs
|
||||||
|
var result = await _client.PostDataAsync<List<ServerCustomerDto>, List<ClientCustomerDto>>(
|
||||||
|
TestSignalRTags.PropertyMismatchListParam, serverDtos);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsNotNull(result, "Result should not be null");
|
||||||
|
Assert.AreEqual(serverDtos.Count, result.Count, $"Expected {serverDtos.Count} items");
|
||||||
|
|
||||||
|
for (int i = 0; i < serverDtos.Count; i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(serverDtos[i].Id, result[i].Id, $"Id mismatch at index {i}");
|
||||||
|
Assert.AreEqual(serverDtos[i].FirstName, result[i].FirstName,
|
||||||
|
$"FirstName mismatch at index {i}: expected '{serverDtos[i].FirstName}', got '{result[i].FirstName}'");
|
||||||
|
Assert.AreEqual(serverDtos[i].LastName, result[i].LastName,
|
||||||
|
$"LastName mismatch at index {i}: expected '{serverDtos[i].LastName}', got '{result[i].LastName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REGRESSION TEST: Tests nested objects being skipped when client doesn't know about them.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public async Task PropertyMismatch_NestedObjectsSkipped_DeserializesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange: Server order with nested customer object
|
||||||
|
var serverOrder = new ServerOrderWithExtras
|
||||||
|
{
|
||||||
|
Id = 100,
|
||||||
|
OrderNumber = "ORD-2024-001",
|
||||||
|
TotalAmount = 999.99m,
|
||||||
|
Customer = new ServerCustomerDto
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
FirstName = "John",
|
||||||
|
LastName = "Doe",
|
||||||
|
Email = "john@example.com",
|
||||||
|
Phone = "+1-555-0001"
|
||||||
|
},
|
||||||
|
RelatedCustomers =
|
||||||
[
|
[
|
||||||
new TestGenericAttribute { Id = 1, Key = "abc", Value = "123" },
|
new ServerCustomerDto { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" },
|
||||||
new TestGenericAttribute { Id = 2, Key = "abcd", Value = "1234" },
|
new ServerCustomerDto { Id = 3, FirstName = "Bob", LastName = "Wilson", Email = "bob@example.com" }
|
||||||
new TestGenericAttribute { Id = 3, Key = "ab", Value = "12" },
|
],
|
||||||
new TestGenericAttribute { Id = 4, Key = "abcde", Value = "12345" },
|
InternalNotes = "Priority processing required",
|
||||||
new TestGenericAttribute { Id = 5, Key = "a", Value = "1" },
|
ProcessingCode = "RUSH-001"
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _client.PostDataAsync<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
// Act: Send server order, receive simplified client order
|
||||||
TestSignalRTags.GenericAttributesParam, dto);
|
var result = await _client.PostDataAsync<ServerOrderWithExtras, ClientOrderSimple>(
|
||||||
|
TestSignalRTags.PropertyMismatchNestedParam, serverOrder);
|
||||||
|
|
||||||
|
// Assert: Client should receive only basic order info
|
||||||
Assert.IsNotNull(result);
|
Assert.IsNotNull(result);
|
||||||
Assert.AreEqual("Test", result.Name);
|
Assert.AreEqual(100, result.Id);
|
||||||
|
Assert.AreEqual("ORD-2024-001", result.OrderNumber);
|
||||||
|
Assert.AreEqual(999.99m, result.TotalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.AreEqual("abc", result.GenericAttributes[0].Key);
|
/// <summary>
|
||||||
Assert.AreEqual("123", result.GenericAttributes[0].Value);
|
/// REGRESSION TEST: Large list with nested objects being skipped.
|
||||||
|
/// This is the most comprehensive test for the SkipValue string interning bug.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public async Task PropertyMismatch_LargeListWithNestedObjects_DeserializesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange: Create 50 orders with nested customers
|
||||||
|
var serverOrders = Enumerable.Range(0, 50).Select(i => new ServerOrderWithExtras
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
OrderNumber = $"ORD-{i:D4}",
|
||||||
|
TotalAmount = i * 100.50m,
|
||||||
|
Customer = new ServerCustomerDto
|
||||||
|
{
|
||||||
|
Id = i * 100,
|
||||||
|
FirstName = $"Customer_{i % 10}",
|
||||||
|
LastName = $"LastName_{i % 8}",
|
||||||
|
Email = $"customer{i}@example.com",
|
||||||
|
Company = $"Company_{i % 5}"
|
||||||
|
},
|
||||||
|
RelatedCustomers = Enumerable.Range(0, i % 3 + 1).Select(j => new ServerCustomerDto
|
||||||
|
{
|
||||||
|
Id = i * 100 + j,
|
||||||
|
FirstName = $"Related_{j}",
|
||||||
|
LastName = $"Contact_{i % 4}",
|
||||||
|
Email = $"related{i}_{j}@example.com"
|
||||||
|
}).ToList(),
|
||||||
|
InternalNotes = $"Notes for order {i}",
|
||||||
|
ProcessingCode = $"CODE-{i % 10}"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
Assert.AreEqual("abcd", result.GenericAttributes[1].Key);
|
// Act
|
||||||
Assert.AreEqual("1234", result.GenericAttributes[1].Value);
|
var result = await _client.PostDataAsync<List<ServerOrderWithExtras>, List<ClientOrderSimple>>(
|
||||||
|
TestSignalRTags.PropertyMismatchNestedListParam, serverOrders);
|
||||||
|
|
||||||
Assert.AreEqual("ab", result.GenericAttributes[2].Key);
|
// Assert
|
||||||
Assert.AreEqual("12", result.GenericAttributes[2].Value);
|
Assert.IsNotNull(result, "Result should not be null - SkipValue should correctly handle unknown nested objects");
|
||||||
|
Assert.AreEqual(serverOrders.Count, result.Count);
|
||||||
|
|
||||||
Assert.AreEqual("abcde", result.GenericAttributes[3].Key);
|
for (int i = 0; i < serverOrders.Count; i++)
|
||||||
Assert.AreEqual("12345", result.GenericAttributes[3].Value);
|
{
|
||||||
|
Assert.AreEqual(serverOrders[i].Id, result[i].Id, $"Id mismatch at index {i}");
|
||||||
Assert.AreEqual("a", result.GenericAttributes[4].Key);
|
Assert.AreEqual(serverOrders[i].OrderNumber, result[i].OrderNumber,
|
||||||
Assert.AreEqual("1", result.GenericAttributes[4].Value);
|
$"OrderNumber mismatch at index {i}: expected '{serverOrders[i].OrderNumber}', got '{result[i].OrderNumber}'");
|
||||||
|
Assert.AreEqual(serverOrders[i].TotalAmount, result[i].TotalAmount, $"TotalAmount mismatch at index {i}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,30 @@ public static class SignalRTestHelper
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static T? GetResponseData<T>(SentMessage sentMessage)
|
public static T? GetResponseData<T>(SentMessage sentMessage)
|
||||||
{
|
{
|
||||||
if (sentMessage.AsJsonResponse?.ResponseData == null)
|
if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null)
|
||||||
return default;
|
{
|
||||||
|
return jsonResponse.ResponseData.JsonTo<T>();
|
||||||
|
}
|
||||||
|
|
||||||
return sentMessage.AsJsonResponse.ResponseData.JsonTo<T>();
|
if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null)
|
||||||
|
{
|
||||||
|
return binaryResponse.ResponseData.BinaryTo<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the response status from either JSON or Binary message.
|
||||||
|
/// </summary>
|
||||||
|
private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message)
|
||||||
|
{
|
||||||
|
return message switch
|
||||||
|
{
|
||||||
|
SignalResponseJsonMessage jsonMsg => jsonMsg.Status,
|
||||||
|
SignalResponseBinaryMessage binaryMsg => binaryMsg.Status,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -68,12 +88,12 @@ public static class SignalRTestHelper
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
|
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
|
||||||
{
|
{
|
||||||
var response = sentMessage.AsJsonResponse;
|
var status = GetResponseStatus(sentMessage.Message);
|
||||||
if (response == null)
|
if (status == null)
|
||||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
|
||||||
|
|
||||||
if (response.Status != SignalResponseStatus.Success)
|
if (status != SignalResponseStatus.Success)
|
||||||
throw new AssertFailedException($"Expected Success status but got {response.Status}");
|
throw new AssertFailedException($"Expected Success status but got {status}");
|
||||||
|
|
||||||
if (sentMessage.MessageTag != expectedTag)
|
if (sentMessage.MessageTag != expectedTag)
|
||||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||||
|
|
@ -84,12 +104,12 @@ public static class SignalRTestHelper
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
|
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
|
||||||
{
|
{
|
||||||
var response = sentMessage.AsJsonResponse;
|
var status = GetResponseStatus(sentMessage.Message);
|
||||||
if (response == null)
|
if (status == null)
|
||||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
|
||||||
|
|
||||||
if (response.Status != SignalResponseStatus.Error)
|
if (status != SignalResponseStatus.Error)
|
||||||
throw new AssertFailedException($"Expected Error status but got {response.Status}");
|
throw new AssertFailedException($"Expected Error status but got {status}");
|
||||||
|
|
||||||
if (sentMessage.MessageTag != expectedTag)
|
if (sentMessage.MessageTag != expectedTag)
|
||||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,76 @@ public class TestSignalRService2
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests Binary serialization with a list of DTOs containing GenericAttributes.
|
||||||
|
/// This simulates the production scenario with large datasets (e.g., 1834 orders).
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.GenericAttributesListParam)]
|
||||||
|
public List<TestDtoWithGenericAttributes> HandleGenericAttributesList(List<TestDtoWithGenericAttributes> dtos)
|
||||||
|
{
|
||||||
|
return dtos;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Large Dataset / List Tests
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests Binary serialization with a list of TestOrder objects.
|
||||||
|
/// Used for testing string interning with deeply nested objects.
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.TestOrderListParam)]
|
||||||
|
public List<TestOrder> HandleTestOrderList(List<TestOrder> orders)
|
||||||
|
{
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Property Mismatch Tests (Server has more properties than Client)
|
||||||
|
// Tests for SkipValue string interning bug fix.
|
||||||
|
// In these tests, the server sends DTOs with more properties than the client knows about.
|
||||||
|
// The client's deserializer must skip unknown properties while maintaining string intern table consistency.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles server DTO and returns the same DTO.
|
||||||
|
/// Client will deserialize this into ClientCustomerDto (fewer properties).
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.PropertyMismatchParam)]
|
||||||
|
public ServerCustomerDto HandlePropertyMismatch(ServerCustomerDto dto)
|
||||||
|
{
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles list of server DTOs.
|
||||||
|
/// Client will deserialize into List<ClientCustomerDto>.
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.PropertyMismatchListParam)]
|
||||||
|
public List<ServerCustomerDto> HandlePropertyMismatchList(List<ServerCustomerDto> dtos)
|
||||||
|
{
|
||||||
|
return dtos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles server order with nested customer objects.
|
||||||
|
/// Client will deserialize into ClientOrderSimple (no nested objects).
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.PropertyMismatchNestedParam)]
|
||||||
|
public ServerOrderWithExtras HandlePropertyMismatchNested(ServerOrderWithExtras order)
|
||||||
|
{
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles list of server orders with nested customer objects.
|
||||||
|
/// Client will deserialize into List<ClientOrderSimple>.
|
||||||
|
/// </summary>
|
||||||
|
[SignalR(TestSignalRTags.PropertyMismatchNestedListParam)]
|
||||||
|
public List<ServerOrderWithExtras> HandlePropertyMismatchNestedList(List<ServerOrderWithExtras> orders)
|
||||||
|
{
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,4 +70,15 @@ public abstract class TestSignalRTags : AcSignalRTags
|
||||||
|
|
||||||
// Binary serialization with GenericAttributes test
|
// Binary serialization with GenericAttributes test
|
||||||
public const int GenericAttributesParam = 220;
|
public const int GenericAttributesParam = 220;
|
||||||
|
public const int GenericAttributesListParam = 221;
|
||||||
|
|
||||||
|
// Large dataset / List tests
|
||||||
|
public const int TestOrderListParam = 230;
|
||||||
|
|
||||||
|
// Property mismatch tests (Server has more properties than Client)
|
||||||
|
// Tests SkipValue string interning bug fix
|
||||||
|
public const int PropertyMismatchParam = 240;
|
||||||
|
public const int PropertyMismatchListParam = 241;
|
||||||
|
public const int PropertyMismatchNestedParam = 242;
|
||||||
|
public const int PropertyMismatchNestedListParam = 243;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using AyCode.Core.Extensions;
|
||||||
using AyCode.Core.Tests.TestModels;
|
using AyCode.Core.Tests.TestModels;
|
||||||
using AyCode.Models.Server.DynamicMethods;
|
using AyCode.Models.Server.DynamicMethods;
|
||||||
using AyCode.Services.Server.SignalRs;
|
using AyCode.Services.Server.SignalRs;
|
||||||
|
|
@ -68,6 +69,16 @@ public class TestableSignalRHub : AcWebSignalRHubBase<TestSignalRTags, TestLogge
|
||||||
|
|
||||||
#region Public Test Entry Points
|
#region Public Test Entry Points
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the serializer type for testing (JSON or Binary).
|
||||||
|
/// </summary>
|
||||||
|
public void SetSerializerType(AcSerializerType serializerType)
|
||||||
|
{
|
||||||
|
SerializerOptions = serializerType == AcSerializerType.Binary
|
||||||
|
? new AcBinarySerializerOptions()
|
||||||
|
: new AcJsonSerializerOptions();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register a service with SignalR-attributed methods
|
/// Register a service with SignalR-attributed methods
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,14 @@ public static class SignalRRequestModelPool
|
||||||
new DefaultObjectPoolProvider().Create<SignalRRequestModel>();
|
new DefaultObjectPoolProvider().Create<SignalRRequestModel>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a SignalRRequestModel from the pool.
|
/// Gets a SignalRRequestModel from the pool and initializes it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static SignalRRequestModel Get() => Pool.Get();
|
public static SignalRRequestModel Get()
|
||||||
|
{
|
||||||
|
var model = Pool.Get();
|
||||||
|
model.Initialize();
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.
|
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue