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);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Size Comparison Tests
|
||||
|
||||
/// <summary>
|
||||
/// REGRESSION TEST: Comprehensive string interning edge cases.
|
||||
///
|
||||
/// 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]
|
||||
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 items = Enumerable.Range(0, 10).Select(i => new TestClassWithLongPropertyNames
|
||||
{
|
||||
FirstProperty = $"Value1_{i}",
|
||||
SecondProperty = $"Value2_{i}",
|
||||
ThirdProperty = $"Value3_{i}",
|
||||
FourthProperty = $"Value4_{i}"
|
||||
}).ToList();
|
||||
|
||||
var jsonBytes = System.Text.Encoding.UTF8.GetBytes(obj.ToJson());
|
||||
var binaryBytes = obj.ToBinary();
|
||||
var binary = items.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestClassWithLongPropertyNames>>();
|
||||
|
||||
Console.WriteLine($"JSON size: {jsonBytes.Length} bytes");
|
||||
Console.WriteLine($"Binary size: {binaryBytes.Length} bytes");
|
||||
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.AreEqual(obj.Id, result.Id);
|
||||
Assert.AreEqual(obj.Items.Count, result.Items.Count);
|
||||
Assert.AreEqual(10, result.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
|
||||
|
||||
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();
|
||||
|
||||
#region Extension Method Tests
|
||||
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]
|
||||
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,
|
||||
Name = "Original",
|
||||
Child = new TestSimpleClass { Id = 2, Name = "Child" }
|
||||
RootName = "RootObject",
|
||||
Level1Items = Enumerable.Range(0, 5).Select(i => new TestLevel1
|
||||
{
|
||||
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.AreNotSame(original, clone);
|
||||
Assert.AreNotSame(original.Child, clone.Child);
|
||||
Assert.AreEqual(original.Id, clone.Id);
|
||||
Assert.AreEqual(original.Child.Id, clone.Child!.Id);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("RootObject", result.RootName);
|
||||
Assert.AreEqual(5, result.Level1Items.Count);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.AreEqual($"Level1_{i}", result.Level1Items[i].Level1Name);
|
||||
Assert.AreEqual(3, result.Level1Items[i].Level2Items.Count);
|
||||
|
||||
for (int j = 0; j < 3; j++)
|
||||
{
|
||||
Assert.AreEqual($"Level2_{i}_{j}", result.Level1Items[i].Level2Items[j].Level2Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modify clone, original should be unchanged
|
||||
clone.Id = 999;
|
||||
clone.Child.Id = 888;
|
||||
Assert.AreEqual(1, original.Id);
|
||||
Assert.AreEqual(2, original.Child.Id);
|
||||
[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
|
||||
|
|
@ -391,6 +653,94 @@ public class AcBinarySerializerTests
|
|||
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
|
||||
|
||||
#region Benchmark Order Tests
|
||||
|
|
|
|||
|
|
@ -378,3 +378,83 @@ public class ObjectWithNullItems
|
|||
}
|
||||
|
||||
#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();
|
||||
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)
|
||||
{
|
||||
|
|
@ -510,7 +518,15 @@ public static class AcBinaryDeserializer
|
|||
var typeCode = context.ReadByte();
|
||||
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)
|
||||
{
|
||||
|
|
@ -844,8 +860,9 @@ public static class AcBinaryDeserializer
|
|||
context.Skip(16);
|
||||
return;
|
||||
case BinaryTypeCode.String:
|
||||
var strLen = (int)context.ReadVarUInt();
|
||||
context.Skip(strLen);
|
||||
// CRITICAL FIX: Must register string in intern table even when skipping!
|
||||
// The serializer registered this string, so we must too to keep indices in sync.
|
||||
SkipAndInternString(ref context);
|
||||
return;
|
||||
case BinaryTypeCode.StringInterned:
|
||||
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)
|
||||
{
|
||||
// Skip ref ID if present
|
||||
|
|
@ -885,7 +920,7 @@ public static class AcBinaryDeserializer
|
|||
var propCount = (int)context.ReadVarUInt();
|
||||
for (int i = 0; i < propCount; i++)
|
||||
{
|
||||
// Skip property name
|
||||
// Skip property name - but must register in intern table!
|
||||
if (context.HasMetadata)
|
||||
{
|
||||
context.ReadVarUInt();
|
||||
|
|
@ -895,9 +930,15 @@ public static class AcBinaryDeserializer
|
|||
var nameCode = context.ReadByte();
|
||||
if (nameCode == BinaryTypeCode.String)
|
||||
{
|
||||
var len = (int)context.ReadVarUInt();
|
||||
context.Skip(len);
|
||||
// CRITICAL FIX: Must register property name in intern table even when skipping!
|
||||
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
|
||||
SkipValue(ref context);
|
||||
|
|
|
|||
|
|
@ -6,20 +6,24 @@ using MessagePack;
|
|||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for ProcessOnReceiveMessage tests.
|
||||
/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage.
|
||||
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
|
||||
/// Derived classes specify the serializer type (JSON or Binary).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ProcessOnReceiveMessageTests
|
||||
public abstract class ProcessOnReceiveMessageTestsBase
|
||||
{
|
||||
private TestableSignalRHub _hub = null!;
|
||||
private TestSignalRService _service = null!;
|
||||
protected abstract AcSerializerType SerializerType { get; }
|
||||
|
||||
protected TestableSignalRHub _hub = null!;
|
||||
protected TestSignalRService _service = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_hub = new TestableSignalRHub();
|
||||
_service = new TestSignalRService();
|
||||
_hub.SetSerializerType(SerializerType);
|
||||
_hub.RegisterService(_service);
|
||||
}
|
||||
|
||||
|
|
@ -1043,3 +1047,21 @@ public class ProcessOnReceiveMessageTests
|
|||
|
||||
#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]
|
||||
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,
|
||||
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" },
|
||||
]
|
||||
FirstName = "John",
|
||||
LastName = "Smith",
|
||||
Email = "john.smith@example.com",
|
||||
Phone = "+1-555-1234",
|
||||
Address = "123 Main Street",
|
||||
City = "New York",
|
||||
Country = "USA",
|
||||
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>(
|
||||
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);
|
||||
|
||||
// Act: Send server DTO, receive client DTO (fewer properties)
|
||||
// This simulates the real bug scenario
|
||||
var result = await _client.PostDataAsync<ServerCustomerDto, ClientCustomerDto>(
|
||||
TestSignalRTags.PropertyMismatchParam, serverDto);
|
||||
|
||||
// Assert: Client should receive only the properties it knows about
|
||||
Assert.IsNotNull(result, "Result should not be null - deserialization should succeed even with unknown properties");
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("John", result.FirstName);
|
||||
Assert.AreEqual("Smith", result.LastName);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
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,
|
||||
Name = "Test",
|
||||
GenericAttributes =
|
||||
Id = i,
|
||||
FirstName = $"FirstName_{i % 10}", // 10 unique values (will be interned)
|
||||
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 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" },
|
||||
]
|
||||
new ServerCustomerDto { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" },
|
||||
new ServerCustomerDto { Id = 3, FirstName = "Bob", LastName = "Wilson", Email = "bob@example.com" }
|
||||
],
|
||||
InternalNotes = "Priority processing required",
|
||||
ProcessingCode = "RUSH-001"
|
||||
};
|
||||
|
||||
var result = await _client.PostDataAsync<TestDtoWithGenericAttributes, TestDtoWithGenericAttributes>(
|
||||
TestSignalRTags.GenericAttributesParam, dto);
|
||||
|
||||
|
||||
// Act: Send server order, receive simplified client order
|
||||
var result = await _client.PostDataAsync<ServerOrderWithExtras, ClientOrderSimple>(
|
||||
TestSignalRTags.PropertyMismatchNestedParam, serverOrder);
|
||||
|
||||
// Assert: Client should receive only basic order info
|
||||
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);
|
||||
Assert.AreEqual(100, result.Id);
|
||||
Assert.AreEqual("ORD-2024-001", result.OrderNumber);
|
||||
Assert.AreEqual(999.99m, result.TotalAmount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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();
|
||||
|
||||
// Act
|
||||
var result = await _client.PostDataAsync<List<ServerOrderWithExtras>, List<ClientOrderSimple>>(
|
||||
TestSignalRTags.PropertyMismatchNestedListParam, serverOrders);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result, "Result should not be null - SkipValue should correctly handle unknown nested objects");
|
||||
Assert.AreEqual(serverOrders.Count, result.Count);
|
||||
|
||||
for (int i = 0; i < serverOrders.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(serverOrders[i].Id, result[i].Id, $"Id mismatch at index {i}");
|
||||
Assert.AreEqual(serverOrders[i].OrderNumber, result[i].OrderNumber,
|
||||
$"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
|
||||
|
|
|
|||
|
|
@ -57,10 +57,30 @@ public static class SignalRTestHelper
|
|||
/// </summary>
|
||||
public static T? GetResponseData<T>(SentMessage sentMessage)
|
||||
{
|
||||
if (sentMessage.AsJsonResponse?.ResponseData == null)
|
||||
return default;
|
||||
if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null)
|
||||
{
|
||||
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>
|
||||
|
|
@ -68,12 +88,12 @@ public static class SignalRTestHelper
|
|||
/// </summary>
|
||||
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
|
||||
{
|
||||
var response = sentMessage.AsJsonResponse;
|
||||
if (response == null)
|
||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
||||
var status = GetResponseStatus(sentMessage.Message);
|
||||
if (status == null)
|
||||
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
|
||||
|
||||
if (response.Status != SignalResponseStatus.Success)
|
||||
throw new AssertFailedException($"Expected Success status but got {response.Status}");
|
||||
if (status != SignalResponseStatus.Success)
|
||||
throw new AssertFailedException($"Expected Success status but got {status}");
|
||||
|
||||
if (sentMessage.MessageTag != expectedTag)
|
||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||
|
|
@ -84,12 +104,12 @@ public static class SignalRTestHelper
|
|||
/// </summary>
|
||||
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
|
||||
{
|
||||
var response = sentMessage.AsJsonResponse;
|
||||
if (response == null)
|
||||
throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
|
||||
var status = GetResponseStatus(sentMessage.Message);
|
||||
if (status == null)
|
||||
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
|
||||
|
||||
if (response.Status != SignalResponseStatus.Error)
|
||||
throw new AssertFailedException($"Expected Error status but got {response.Status}");
|
||||
if (status != SignalResponseStatus.Error)
|
||||
throw new AssertFailedException($"Expected Error status but got {status}");
|
||||
|
||||
if (sentMessage.MessageTag != expectedTag)
|
||||
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
|
||||
|
|
|
|||
|
|
@ -356,6 +356,76 @@ public class TestSignalRService2
|
|||
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
|
||||
|
||||
#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
|
||||
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 AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
|
|
@ -68,6 +69,16 @@ public class TestableSignalRHub : AcWebSignalRHubBase<TestSignalRTags, TestLogge
|
|||
|
||||
#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>
|
||||
/// Register a service with SignalR-attributed methods
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -54,9 +54,14 @@ public static class SignalRRequestModelPool
|
|||
new DefaultObjectPoolProvider().Create<SignalRRequestModel>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a SignalRRequestModel from the pool.
|
||||
/// Gets a SignalRRequestModel from the pool and initializes it.
|
||||
/// </summary>
|
||||
public static SignalRRequestModel Get() => Pool.Get();
|
||||
public static SignalRRequestModel Get()
|
||||
{
|
||||
var model = Pool.Get();
|
||||
model.Initialize();
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.
|
||||
|
|
|
|||
Loading…
Reference in New Issue