a43b2ee6af
Add LastOwnerKeyId capture to FakeSessionManager and assert it equals "operator01" in OpenSession_WithValidRequest_ReturnsSessionDetails, closing the gap where OwnerKeyId threading through the service layer had no test coverage. Add a <remarks> to the 11-param GatewaySession convenience ctor documenting that OwnerKeyId is null there and authenticated call sites must use the 12-param overload.
726 lines
28 KiB
C#
726 lines
28 KiB
C#
using System.Diagnostics.Metrics;
|
|
using System.Runtime.CompilerServices;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Grpc;
|
|
|
|
public sealed class MxAccessGatewayServiceTests
|
|
{
|
|
/// <summary>Verifies that OpenSession returns correct session details for a valid request.</summary>
|
|
[Fact]
|
|
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
|
|
{
|
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
|
FakeSessionManager sessionManager = new()
|
|
{
|
|
OpenSessionResult = CreateSession("session-1", processId: 4321),
|
|
};
|
|
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
|
|
|
|
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
|
|
OpenSessionReply reply = await service.OpenSession(
|
|
new OpenSessionRequest
|
|
{
|
|
ClientSessionName = "operator-session",
|
|
CommandTimeout = Duration.FromTimeSpan(TimeSpan.FromSeconds(7)),
|
|
},
|
|
new TestServerCallContext());
|
|
|
|
Assert.Equal("session-1", reply.SessionId);
|
|
Assert.Equal(GatewayContractInfo.DefaultBackendName, reply.BackendName);
|
|
Assert.Equal(4321, reply.WorkerProcessId);
|
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
Assert.Contains("unary-invoke", reply.Capabilities);
|
|
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
|
|
Assert.Equal("operator01", sessionManager.LastOwnerKeyId);
|
|
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
|
|
/// service's own <c>ResolveSession</c> lookup. No <c>InvokeException</c> is
|
|
/// injected — <see cref="FakeSessionManager.ResolveOnlySeededSessions"/> makes
|
|
/// <c>TryGetSession</c> return false, so this test fails if the service drops
|
|
/// its missing-session check rather than passing for the wrong reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
|
|
{
|
|
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
|
|
MxAccessGatewayService service = CreateService(sessionManager);
|
|
|
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
async () => await service.Invoke(
|
|
CreatePingRequest("session-missing"),
|
|
new TestServerCallContext()));
|
|
|
|
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
|
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
|
|
// The service must reject before delegating to the session manager.
|
|
Assert.Equal(0, sessionManager.InvokeCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Invoke resolves a session that was seeded into the session
|
|
/// manager when <see cref="FakeSessionManager.ResolveOnlySeededSessions"/> is on,
|
|
/// confirming the missing-session test above is gated on a real lookup.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Invoke_WhenSessionSeeded_ResolvesAndInvokes()
|
|
{
|
|
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
|
|
sessionManager.SeedSession(CreateSession("session-1", processId: 1234));
|
|
MxAccessGatewayService service = CreateService(sessionManager);
|
|
|
|
MxCommandReply reply = await service.Invoke(
|
|
CreatePingRequest("session-1"),
|
|
new TestServerCallContext());
|
|
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
Assert.Equal(1, sessionManager.InvokeCount);
|
|
}
|
|
|
|
/// <summary>Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched.</summary>
|
|
[Fact]
|
|
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
MxAccessGatewayService service = CreateService(sessionManager);
|
|
MxCommandRequest request = new()
|
|
{
|
|
SessionId = "session-1",
|
|
Command = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.AddItem,
|
|
Ping = new PingCommand { Message = "wrong-payload" },
|
|
},
|
|
};
|
|
|
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
async () => await service.Invoke(request, new TestServerCallContext()));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
Assert.Equal(0, sessionManager.InvokeCount);
|
|
}
|
|
|
|
/// <summary>Verifies that Invoke returns HResult status and method payload from worker reply.</summary>
|
|
[Fact]
|
|
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
|
|
{
|
|
const int hresult = unchecked((int)0x80004005);
|
|
FakeSessionManager sessionManager = new()
|
|
{
|
|
InvokeReply = new WorkerCommandReply
|
|
{
|
|
Reply = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
CorrelationId = "worker-correlation",
|
|
Kind = MxCommandKind.AddItem,
|
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
|
Hresult = hresult,
|
|
AddItem = new AddItemReply { ItemHandle = 42 },
|
|
DiagnosticMessage = "mxaccess diagnostic",
|
|
},
|
|
},
|
|
};
|
|
sessionManager.InvokeReply.Reply.Statuses.Add(new MxStatusProxy
|
|
{
|
|
Success = 0,
|
|
Category = MxStatusCategory.SoftwareError,
|
|
Detail = 1001,
|
|
DiagnosticText = "status detail",
|
|
});
|
|
MxAccessGatewayService service = CreateService(sessionManager);
|
|
MxCommandRequest request = new()
|
|
{
|
|
SessionId = "session-1",
|
|
ClientCorrelationId = "client-correlation",
|
|
Command = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.AddItem,
|
|
AddItem = new AddItemCommand
|
|
{
|
|
ServerHandle = 12,
|
|
ItemDefinition = "Galaxy.Tag.Value",
|
|
},
|
|
},
|
|
};
|
|
|
|
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
|
|
|
|
Assert.Equal(MxCommandKind.AddItem, sessionManager.LastWorkerCommand?.Command.Kind);
|
|
Assert.Equal("Galaxy.Tag.Value", sessionManager.LastWorkerCommand?.Command.AddItem.ItemDefinition);
|
|
Assert.NotNull(sessionManager.LastWorkerCommand?.EnqueueTimestamp);
|
|
Assert.Equal(hresult, reply.Hresult);
|
|
Assert.Equal(42, reply.AddItem.ItemHandle);
|
|
Assert.Equal("status detail", Assert.Single(reply.Statuses).DiagnosticText);
|
|
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that StreamEvents writes only events after the specified worker sequence.</summary>
|
|
[Fact]
|
|
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1));
|
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
|
MxAccessGatewayService service = CreateService(sessionManager);
|
|
RecordingServerStreamWriter<MxEvent> writer = new();
|
|
|
|
await service.StreamEvents(
|
|
new StreamEventsRequest
|
|
{
|
|
SessionId = "session-1",
|
|
AfterWorkerSequence = 1,
|
|
},
|
|
writer,
|
|
new TestServerCallContext());
|
|
|
|
MxEvent writtenEvent = Assert.Single(writer.Messages);
|
|
Assert.Equal((ulong)2, writtenEvent.WorkerSequence);
|
|
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that <c>StreamEvents</c> records the send-duration histogram per event.
|
|
///
|
|
/// <para>Tests-027 (concurrency flake): the listener must filter by the specific
|
|
/// <see cref="System.Diagnostics.Metrics.Meter"/> instance owned by this test, not by the process-shared
|
|
/// <see cref="GatewayMetrics.MeterName"/>. Otherwise a parallel test that constructs its own
|
|
/// <see cref="GatewayMetrics"/> and records <c>mxgateway.events.stream_send.duration</c> would
|
|
/// cross-contaminate <c>families</c> and break the equality assertion below. See the companion
|
|
/// <see cref="StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName"/>
|
|
/// regression for the cross-talk reproduction.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
|
|
{
|
|
using GatewayMetrics metrics = new();
|
|
using MeterListener listener = new();
|
|
List<string> families = [];
|
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
|
{
|
|
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
|
&& instrument.Name == "mxgateway.events.stream_send.duration")
|
|
{
|
|
meterListener.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
listener.SetMeasurementEventCallback<double>(
|
|
(instrument, measurement, tags, _) =>
|
|
{
|
|
if (!ReferenceEquals(instrument.Meter, metrics.Meter)
|
|
|| instrument.Name != "mxgateway.events.stream_send.duration")
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (KeyValuePair<string, object?> tag in tags)
|
|
{
|
|
if (tag.Key == "family" && tag.Value is string family)
|
|
{
|
|
families.Add(family);
|
|
}
|
|
}
|
|
});
|
|
listener.Start();
|
|
FakeSessionManager sessionManager = new();
|
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
|
MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics);
|
|
RecordingServerStreamWriter<MxEvent> writer = new();
|
|
|
|
await service.StreamEvents(
|
|
new StreamEventsRequest { SessionId = "session-1" },
|
|
writer,
|
|
new TestServerCallContext());
|
|
|
|
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests-027 regression: a <see cref="MeterListener"/> that filters by the specific
|
|
/// <see cref="System.Diagnostics.Metrics.Meter"/> instance (via <see cref="object.ReferenceEquals"/>)
|
|
/// must NOT observe measurements recorded on a different <see cref="GatewayMetrics"/> that shares
|
|
/// the same <see cref="GatewayMetrics.MeterName"/>. This is the cross-talk vector that previously
|
|
/// caused <c>StreamEvents_WhenEventIsWritten_RecordsSendDuration</c> to fail intermittently when
|
|
/// run in parallel with another test recording the same histogram.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName()
|
|
{
|
|
using GatewayMetrics metricsUnderTest = new();
|
|
using GatewayMetrics otherMetrics = new();
|
|
using MeterListener listener = new();
|
|
List<string> families = [];
|
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
|
{
|
|
// Subscribe to the stream_send histogram on BOTH meters so the listener
|
|
// would observe a cross-talk measurement if the callback did not filter.
|
|
if (instrument.Name == "mxgateway.events.stream_send.duration")
|
|
{
|
|
meterListener.EnableMeasurementEvents(instrument);
|
|
}
|
|
};
|
|
listener.SetMeasurementEventCallback<double>(
|
|
(instrument, measurement, tags, _) =>
|
|
{
|
|
if (!ReferenceEquals(instrument.Meter, metricsUnderTest.Meter)
|
|
|| instrument.Name != "mxgateway.events.stream_send.duration")
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (KeyValuePair<string, object?> tag in tags)
|
|
{
|
|
if (tag.Key == "family" && tag.Value is string family)
|
|
{
|
|
families.Add(family);
|
|
}
|
|
}
|
|
});
|
|
listener.Start();
|
|
|
|
// Simulate the cross-talk: another test's GatewayMetrics records a value
|
|
// before the test-under-test does its single event publish. The listener
|
|
// must filter this out by Meter reference.
|
|
otherMetrics.RecordEventStreamSend(MxEventFamily.OnWriteComplete.ToString(), TimeSpan.FromMilliseconds(123));
|
|
|
|
FakeSessionManager sessionManager = new();
|
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
|
MxAccessGatewayService service = CreateService(sessionManager, metrics: metricsUnderTest);
|
|
RecordingServerStreamWriter<MxEvent> writer = new();
|
|
|
|
await service.StreamEvents(
|
|
new StreamEventsRequest { SessionId = "session-1" },
|
|
writer,
|
|
new TestServerCallContext());
|
|
|
|
// Only the test-under-test's OnDataChange recording should be observed —
|
|
// the OnAlarm recording on the sibling meter must NOT leak through.
|
|
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
|
|
}
|
|
|
|
/// <summary>Verifies that CloseSession throws InvalidArgument when session ID is blank.</summary>
|
|
[Fact]
|
|
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
|
|
{
|
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
|
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
async () => await service.CloseSession(
|
|
new CloseSessionRequest(),
|
|
new TestServerCallContext()));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
}
|
|
|
|
// ===== AcknowledgeAlarm + StreamAlarms handler contract =====
|
|
//
|
|
// AcknowledgeAlarm validates alarm_full_reference then delegates to the
|
|
// session-less IGatewayAlarmService; StreamAlarms forwards the central
|
|
// alarm feed. CreateService injects FakeGatewayAlarmService.
|
|
|
|
/// <summary>Verifies AcknowledgeAlarm rejects an empty alarm_full_reference.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument()
|
|
{
|
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
|
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
async () => await service.AcknowledgeAlarm(
|
|
new AcknowledgeAlarmRequest { OperatorUser = "alice" },
|
|
new TestServerCallContext()));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService()
|
|
{
|
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
|
|
AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm(
|
|
new AcknowledgeAlarmRequest
|
|
{
|
|
ClientCorrelationId = "corr-1",
|
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
Comment = "investigating",
|
|
OperatorUser = "alice",
|
|
},
|
|
new TestServerCallContext());
|
|
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
Assert.Equal("corr-1", reply.CorrelationId);
|
|
}
|
|
|
|
/// <summary>Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete.</summary>
|
|
[Fact]
|
|
public async Task StreamAlarms_ForwardsTheCentralAlarmFeed()
|
|
{
|
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
RecordingServerStreamWriter<AlarmFeedMessage> sink = new();
|
|
|
|
await service.StreamAlarms(
|
|
new StreamAlarmsRequest(),
|
|
sink,
|
|
new TestServerCallContext());
|
|
|
|
Assert.Contains(
|
|
sink.Messages,
|
|
message => message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete);
|
|
}
|
|
|
|
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
|
|
[Fact]
|
|
public async Task OpenSession_AdvertisesAlarmRpcCapabilities()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
|
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
|
|
|
|
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
|
|
|
|
OpenSessionReply reply = await service.OpenSession(
|
|
new OpenSessionRequest(),
|
|
new TestServerCallContext());
|
|
|
|
Assert.Contains("unary-acknowledge-alarm", reply.Capabilities);
|
|
Assert.Contains("server-stream-active-alarms", reply.Capabilities);
|
|
}
|
|
|
|
private static MxAccessGatewayService CreateService(
|
|
FakeSessionManager sessionManager,
|
|
IGatewayRequestIdentityAccessor? identityAccessor = null,
|
|
GatewayMetrics? metrics = null,
|
|
IConstraintEnforcer? constraintEnforcer = null)
|
|
{
|
|
return new MxAccessGatewayService(
|
|
sessionManager,
|
|
identityAccessor ?? new GatewayRequestIdentityAccessor(),
|
|
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
|
|
new MxAccessGrpcRequestValidator(),
|
|
new MxAccessGrpcMapper(),
|
|
new FakeEventStreamService(sessionManager),
|
|
metrics ?? new GatewayMetrics(),
|
|
NullLogger<MxAccessGatewayService>.Instance,
|
|
new FakeGatewayAlarmService());
|
|
}
|
|
|
|
private static ApiKeyIdentity CreateIdentity()
|
|
{
|
|
return new ApiKeyIdentity(
|
|
KeyId: "operator01",
|
|
KeyPrefix: "mxgw_operator01",
|
|
DisplayName: "Operator Key",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal));
|
|
}
|
|
|
|
private static GatewaySession CreateSession(
|
|
string sessionId,
|
|
int processId)
|
|
{
|
|
GatewaySession session = new(
|
|
sessionId,
|
|
GatewayContractInfo.DefaultBackendName,
|
|
"pipe",
|
|
"nonce",
|
|
"Operator Key",
|
|
"operator-session",
|
|
"client-correlation",
|
|
TimeSpan.FromSeconds(7),
|
|
TimeSpan.FromSeconds(30),
|
|
TimeSpan.FromSeconds(10),
|
|
DateTimeOffset.UtcNow);
|
|
session.AttachWorkerClient(new FakeWorkerClient(processId));
|
|
session.MarkReady();
|
|
|
|
return session;
|
|
}
|
|
|
|
private static MxCommandRequest CreatePingRequest(string sessionId)
|
|
{
|
|
return new MxCommandRequest
|
|
{
|
|
SessionId = sessionId,
|
|
Command = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.Ping,
|
|
Ping = new PingCommand { Message = "ping" },
|
|
},
|
|
};
|
|
}
|
|
|
|
private static WorkerEvent CreateWorkerEvent(
|
|
string sessionId,
|
|
ulong workerSequence)
|
|
{
|
|
return new WorkerEvent
|
|
{
|
|
Event = new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
SessionId = sessionId,
|
|
WorkerSequence = workerSequence,
|
|
OnDataChange = new OnDataChangeEvent(),
|
|
},
|
|
};
|
|
}
|
|
|
|
private sealed class FakeSessionManager : ISessionManager
|
|
{
|
|
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>The session to return from OpenSessionAsync.</summary>
|
|
public GatewaySession? OpenSessionResult { get; init; }
|
|
|
|
/// <summary>
|
|
/// When true, <see cref="TryGetSession"/> only resolves sessions that have been
|
|
/// explicitly seeded via <see cref="SeedSession"/> (or <see cref="OpenSessionResult"/>),
|
|
/// and returns false for any other id. This exercises the gateway service's own
|
|
/// missing-session handling instead of masking it with a synthesized session.
|
|
/// </summary>
|
|
public bool ResolveOnlySeededSessions { get; init; }
|
|
|
|
/// <summary>Registers a session so <see cref="TryGetSession"/> resolves its id.</summary>
|
|
/// <param name="session">Session to register by its <see cref="GatewaySession.SessionId"/>.</param>
|
|
public void SeedSession(GatewaySession session)
|
|
{
|
|
seededSessions[session.SessionId] = session;
|
|
}
|
|
|
|
/// <summary>The last OpenSessionAsync request captured.</summary>
|
|
public SessionOpenRequest? LastOpenRequest { get; private set; }
|
|
|
|
/// <summary>The last client identity passed to OpenSessionAsync.</summary>
|
|
public string? LastClientIdentity { get; private set; }
|
|
|
|
/// <summary>The last owner key id passed to OpenSessionAsync.</summary>
|
|
public string? LastOwnerKeyId { get; private set; }
|
|
|
|
/// <summary>The last session ID passed to ReadEventsAsync.</summary>
|
|
public string? LastReadEventsSessionId { get; private set; }
|
|
|
|
/// <summary>The last worker command passed to InvokeAsync.</summary>
|
|
public WorkerCommand? LastWorkerCommand { get; private set; }
|
|
|
|
/// <summary>The reply to return from InvokeAsync.</summary>
|
|
public WorkerCommandReply InvokeReply { get; init; } = new()
|
|
{
|
|
Reply = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
Kind = MxCommandKind.Ping,
|
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
|
},
|
|
};
|
|
|
|
/// <summary>The exception to throw from InvokeAsync.</summary>
|
|
public Exception? InvokeException { get; init; }
|
|
|
|
/// <summary>The number of times InvokeAsync was called.</summary>
|
|
public int InvokeCount { get; private set; }
|
|
|
|
/// <summary>The events to return from ReadEventsAsync.</summary>
|
|
public List<WorkerEvent> Events { get; } = [];
|
|
|
|
/// <summary>Records the session ID passed to ReadEventsAsync.</summary>
|
|
/// <param name="sessionId">Identifier of the session.</param>
|
|
public void RecordReadEventsSessionId(string sessionId)
|
|
{
|
|
LastReadEventsSessionId = sessionId;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<GatewaySession> OpenSessionAsync(
|
|
SessionOpenRequest request,
|
|
string? clientIdentity,
|
|
string? ownerKeyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LastOpenRequest = request;
|
|
LastClientIdentity = clientIdentity;
|
|
LastOwnerKeyId = ownerKeyId;
|
|
|
|
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGetSession(
|
|
string sessionId,
|
|
out GatewaySession session)
|
|
{
|
|
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
|
|
{
|
|
session = seeded;
|
|
return true;
|
|
}
|
|
|
|
if (ResolveOnlySeededSessions)
|
|
{
|
|
session = null!;
|
|
return false;
|
|
}
|
|
|
|
session = OpenSessionResult ?? CreateSession(sessionId, processId: 1234);
|
|
return true;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<WorkerCommandReply> InvokeAsync(
|
|
string sessionId,
|
|
WorkerCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
InvokeCount++;
|
|
LastWorkerCommand = command;
|
|
|
|
if (InvokeException is not null)
|
|
{
|
|
throw InvokeException;
|
|
}
|
|
|
|
return Task.FromResult(InvokeReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
|
string sessionId,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
LastReadEventsSessionId = sessionId;
|
|
foreach (WorkerEvent workerEvent in Events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return workerEvent;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> CloseSessionAsync(
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> KillWorkerAsync(
|
|
string sessionId,
|
|
string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> CloseExpiredLeasesAsync(
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
|
|
{
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
StreamEventsRequest request,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
sessionManager.RecordReadEventsSessionId(request.SessionId);
|
|
foreach (WorkerEvent workerEvent in sessionManager.Events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
if (workerEvent.Event.WorkerSequence <= request.AfterWorkerSequence)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
yield return workerEvent.Event;
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FakeWorkerClient(int processId) : IWorkerClient
|
|
{
|
|
/// <inheritdoc />
|
|
public string SessionId { get; } = "session-1";
|
|
|
|
/// <inheritdoc />
|
|
public int? ProcessId { get; } = processId;
|
|
|
|
/// <inheritdoc />
|
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
|
|
|
/// <inheritdoc />
|
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
|
|
|
/// <inheritdoc />
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<WorkerCommandReply> InvokeAsync(
|
|
WorkerCommand command,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new WorkerCommandReply());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.CompletedTask;
|
|
yield break;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Kill(string reason)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
}
|