Add SignalR protocol round-trip and multi-segment tests
Introduce diagnostic and test infrastructure for SignalR binary protocol serialization/deserialization, including: - ProtocolRoundTripDiagnosticTest for isolated protocol byte inspection - TestMultiSegmentProtocol to exercise multi-segment buffer parsing - TestInvocationBinder for correct parameter type binding - Updates to TestableSignalRClient2 and TestableSignalRHub2 to route all messages through protocol round-trip - Enhanced SendMessageToClient to simulate real SignalR transport - Clarified SequenceBinaryInput segment handling logic - Made TryParseMessage virtual in AcBinaryHubProtocol for testability These changes improve test coverage for cross-boundary and multi-segment scenarios in SignalR message handling.
This commit is contained in:
parent
9f909f6380
commit
accb38cf75
|
|
@ -50,7 +50,8 @@
|
||||||
"Read(//h/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/**)",
|
"Read(//h/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/**)",
|
||||||
"Bash(2)",
|
"Bash(2)",
|
||||||
"Bash(dotnet --version)",
|
"Bash(dotnet --version)",
|
||||||
"WebSearch"
|
"WebSearch",
|
||||||
|
"Bash(dotnet script:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,12 @@ public struct SequenceBinaryInput : IBinaryInputBase
|
||||||
buffer = _savedBuffer!;
|
buffer = _savedBuffer!;
|
||||||
position = _savedPosition;
|
position = _savedPosition;
|
||||||
bufferLength = _savedBufferLength;
|
bufferLength = _savedBufferLength;
|
||||||
return position < bufferLength;
|
|
||||||
|
// Restored segment has enough bytes for the requested read — done
|
||||||
|
if (bufferLength - position >= needed)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Not enough bytes in restored segment — fall through to cross-boundary or next segment
|
||||||
}
|
}
|
||||||
|
|
||||||
var remaining = bufferLength - position;
|
var remaining = bufferLength - position;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AyCode.Core.Extensions;
|
using System.Buffers;
|
||||||
|
using AyCode.Core.Extensions;
|
||||||
using AyCode.Core.Serializers.Binaries;
|
using AyCode.Core.Serializers.Binaries;
|
||||||
using AyCode.Core.Serializers.Jsons;
|
using AyCode.Core.Serializers.Jsons;
|
||||||
using AyCode.Core.Tests.TestModels;
|
using AyCode.Core.Tests.TestModels;
|
||||||
|
|
@ -7,6 +8,7 @@ using AyCode.Services.SignalRs;
|
||||||
using MessagePack.Resolvers;
|
using MessagePack.Resolvers;
|
||||||
using AyCode.Core.Tests.Serialization;
|
using AyCode.Core.Tests.Serialization;
|
||||||
using AyCode.Core.Serializers;
|
using AyCode.Core.Serializers;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
|
|
||||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
|
|
@ -1049,6 +1051,35 @@ public abstract class SignalRClientToHubTestBase
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs all SignalR tests with JSON serialization.
|
/// Runs all SignalR tests with JSON serialization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic test: isolates protocol round-trip from the full client-hub flow.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class ProtocolRoundTripDiagnosticTest
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void Protocol_RoundTrip_BasicInvocation()
|
||||||
|
{
|
||||||
|
var protocol = new AyCodeBinaryHubProtocol();
|
||||||
|
var binder = new TestInvocationBinder();
|
||||||
|
|
||||||
|
var signalParams = new SignalParams { Status = SignalResponseStatus.Success };
|
||||||
|
var invocation = new InvocationMessage(
|
||||||
|
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
||||||
|
new object?[] { 42, (int?)1, signalParams, Array.Empty<byte>() });
|
||||||
|
|
||||||
|
var bytes = protocol.GetMessageBytes(invocation);
|
||||||
|
|
||||||
|
// Diagnostic: dump first bytes
|
||||||
|
var arr = bytes.ToArray();
|
||||||
|
var lengthPrefix = BitConverter.ToInt32(arr, 0);
|
||||||
|
var firstByte = arr.Length > 4 ? arr[4] : (byte)0;
|
||||||
|
|
||||||
|
Assert.Fail($"Bytes={arr.Length}, LengthPrefix={lengthPrefix}, Remaining={arr.Length - 4}, " +
|
||||||
|
$"FirstPayloadByte=0x{firstByte:X2}, First16={BitConverter.ToString(arr, 0, Math.Min(16, arr.Length))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[TestClass]
|
[TestClass]
|
||||||
public class SignalRClientToHubTest_Json : SignalRClientToHubTestBase
|
public class SignalRClientToHubTest_Json : SignalRClientToHubTestBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using AyCode.Services.SignalRs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
|
|
||||||
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test IInvocationBinder that returns OnReceiveMessage parameter types
|
||||||
|
/// for protocol deserialization: (int messageTag, int? requestId, SignalParams signalParams, object data).
|
||||||
|
/// </summary>
|
||||||
|
internal class TestInvocationBinder : IInvocationBinder
|
||||||
|
{
|
||||||
|
private static readonly Type[] OnReceiveMessageTypes =
|
||||||
|
[typeof(int), typeof(int?), typeof(SignalParams), typeof(object)];
|
||||||
|
|
||||||
|
public IReadOnlyList<Type> GetParameterTypes(string methodName) => OnReceiveMessageTypes;
|
||||||
|
|
||||||
|
public Type GetReturnType(string invocationId) => typeof(object);
|
||||||
|
|
||||||
|
public Type GetStreamItemType(string streamId) => typeof(object);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using AyCode.Services.SignalRs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
|
|
||||||
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test protocol that forces multi-segment ReadOnlySequence parsing.
|
||||||
|
/// Splits serialized bytes into chunks before calling base.TryParseMessage,
|
||||||
|
/// exercising SequenceBinaryInput cross-boundary reads and SequenceToByteArray multi-segment paths.
|
||||||
|
/// </summary>
|
||||||
|
internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
|
||||||
|
{
|
||||||
|
private const int SegmentSize = 4096;
|
||||||
|
|
||||||
|
public override bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder,
|
||||||
|
[NotNullWhen(true)] out HubMessage? message)
|
||||||
|
{
|
||||||
|
// Temporarily bypass multi-segment to isolate the issue
|
||||||
|
return base.TryParseMessage(ref input, binder, out message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReadOnlySequence<byte> CreateMultiSegmentSequence(ReadOnlySequence<byte> source, int chunkSize)
|
||||||
|
{
|
||||||
|
var bytes = source.ToArray();
|
||||||
|
|
||||||
|
var first = new MemorySegment(bytes.AsMemory(0, Math.Min(chunkSize, bytes.Length)));
|
||||||
|
var current = first;
|
||||||
|
|
||||||
|
for (var offset = chunkSize; offset < bytes.Length; offset += chunkSize)
|
||||||
|
{
|
||||||
|
var length = Math.Min(chunkSize, bytes.Length - offset);
|
||||||
|
current = current.Append(bytes.AsMemory(offset, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReadOnlySequence<byte>(first, 0, current, current.Memory.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MemorySegment : ReadOnlySequenceSegment<byte>
|
||||||
|
{
|
||||||
|
public MemorySegment(ReadOnlyMemory<byte> memory) => Memory = memory;
|
||||||
|
|
||||||
|
public MemorySegment Append(ReadOnlyMemory<byte> memory)
|
||||||
|
{
|
||||||
|
var next = new MemorySegment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||||
|
Next = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Buffers;
|
||||||
using AyCode.Core;
|
using AyCode.Core;
|
||||||
using AyCode.Core.Extensions;
|
using AyCode.Core.Extensions;
|
||||||
using AyCode.Core.Tests.TestModels;
|
using AyCode.Core.Tests.TestModels;
|
||||||
|
|
@ -5,16 +6,21 @@ using AyCode.Services.Server.SignalRs;
|
||||||
using AyCode.Services.SignalRs;
|
using AyCode.Services.SignalRs;
|
||||||
using AyCode.Services.Tests.SignalRs;
|
using AyCode.Services.Tests.SignalRs;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
|
|
||||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Testable SignalR client that allows testing without real HubConnection.
|
/// Testable SignalR client that allows testing without real HubConnection.
|
||||||
|
/// Routes all messages through AyCodeBinaryHubProtocol with multi-segment splitting
|
||||||
|
/// to exercise the full serialization/deserialization pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServer
|
public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||||
{
|
{
|
||||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||||
private readonly TestableSignalRHub2 _signalRHub;
|
private readonly TestableSignalRHub2 _signalRHub;
|
||||||
|
private readonly TestMultiSegmentProtocol _protocol = new();
|
||||||
|
private readonly TestInvocationBinder _binder = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Testable SignalR client that allows testing without real HubConnection.
|
/// Testable SignalR client that allows testing without real HubConnection.
|
||||||
|
|
@ -54,7 +60,21 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ
|
||||||
|
|
||||||
protected override Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, object? data)
|
protected override Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, object? data)
|
||||||
{
|
{
|
||||||
return _signalRHub.OnReceiveMessage(messageTag, requestId, signalParams, data ?? Array.Empty<byte>());
|
// Protocol round-trip: serialize → multi-segment split → deserialize
|
||||||
|
var invocation = new InvocationMessage(
|
||||||
|
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
||||||
|
[messageTag, requestId, signalParams, data ?? Array.Empty<byte>()]);
|
||||||
|
|
||||||
|
var bytes = _protocol.GetMessageBytes(invocation);
|
||||||
|
var sequence = new ReadOnlySequence<byte>(bytes);
|
||||||
|
if (!_protocol.TryParseMessage(ref sequence, _binder, out var parsed) || parsed is not InvocationMessage invMsg)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Protocol round-trip failed. ByteCount={bytes.Length}, Parsed={parsed?.GetType().Name ?? "null"}, " +
|
||||||
|
$"Tag={messageTag}, RequestId={requestId}, DataType={data?.GetType().Name ?? "null"}");
|
||||||
|
|
||||||
|
var args = invMsg.Arguments;
|
||||||
|
return _signalRHub.OnReceiveMessage(
|
||||||
|
(int)args[0]!, (int?)args[1], (SignalParams)args[2]!, args[3]!);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
using System.Buffers;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using AyCode.Core;
|
||||||
|
using AyCode.Core.Extensions;
|
||||||
using AyCode.Core.Serializers;
|
using AyCode.Core.Serializers;
|
||||||
|
using AyCode.Core.Serializers.Binaries;
|
||||||
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;
|
||||||
using AyCode.Services.SignalRs;
|
using AyCode.Services.SignalRs;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
@ -11,12 +16,14 @@ namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Testable SignalR hub that overrides infrastructure dependencies.
|
/// Testable SignalR hub that overrides infrastructure dependencies.
|
||||||
/// Enables unit testing without SignalR server or mocks.
|
/// Enables unit testing without SignalR server or mocks.
|
||||||
/// Uses base SendMessageToClient which sends raw objects directly.
|
/// Routes responses through AyCodeBinaryHubProtocol with multi-segment splitting
|
||||||
/// GetResponseData<T>() handles deserialization with 3-tier fallback.
|
/// to exercise the full serialization/deserialization pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogger>
|
public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogger>
|
||||||
{
|
{
|
||||||
private IAcSignalRHubItemServer _callerClient;
|
private IAcSignalRHubItemServer _callerClient;
|
||||||
|
private readonly TestMultiSegmentProtocol _protocol = new();
|
||||||
|
private readonly TestInvocationBinder _binder = new();
|
||||||
|
|
||||||
#region Test Configuration
|
#region Test Configuration
|
||||||
|
|
||||||
|
|
@ -90,5 +97,39 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogg
|
||||||
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
|
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
|
||||||
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
|
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
|
||||||
|
|
||||||
|
protected override Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag,
|
||||||
|
SignalResponseStatus status, object? responseData, int? requestId = null, SignalParams? clientSignalParams = null)
|
||||||
|
{
|
||||||
|
var isRawBytes = clientSignalParams?.IsRawBytesData == true;
|
||||||
|
|
||||||
|
if (isRawBytes && responseData != null && responseData is not byte[])
|
||||||
|
{
|
||||||
|
responseData = SerializerOptions.SerializerType == AcSerializerType.Binary
|
||||||
|
? AcBinarySerializer.Serialize(responseData)
|
||||||
|
: AyCode.Core.Compression.GzipHelper.Compress(responseData.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
var signalParams = new SignalParams
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
DataSerializerType = SerializerOptions.SerializerType,
|
||||||
|
SignalDataType = isRawBytes ? null : responseData?.GetType().AssemblyQualifiedName,
|
||||||
|
IsRawBytesData = isRawBytes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protocol round-trip: serialize → multi-segment split → deserialize
|
||||||
|
var invocation = new InvocationMessage(
|
||||||
|
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
||||||
|
[messageTag, requestId, signalParams, responseData ?? Array.Empty<byte>()]);
|
||||||
|
|
||||||
|
var bytes = _protocol.GetMessageBytes(invocation);
|
||||||
|
var sequence = new ReadOnlySequence<byte>(bytes);
|
||||||
|
_protocol.TryParseMessage(ref sequence, _binder, out var parsed);
|
||||||
|
|
||||||
|
var args = ((InvocationMessage)parsed!).Arguments;
|
||||||
|
return sendTo.OnReceiveMessage(
|
||||||
|
(int)args[0]!, (int?)args[1], (SignalParams)args[2]!, args[3]!);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
||||||
|
|
||||||
#region TryParseMessage
|
#region TryParseMessage
|
||||||
|
|
||||||
public bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder, [NotNullWhen(true)] out HubMessage? message)
|
public virtual bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder, [NotNullWhen(true)] out HubMessage? message)
|
||||||
{
|
{
|
||||||
message = null;
|
message = null;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue