0765eb4de3
Seventh PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Depends on PR A.1 (proto, merged) and E.1 (regen, merged). Hand-written .NET SDK methods on top of the regenerated proto types: - MxGatewayClient.AcknowledgeAlarmAsync — routes through the existing safe-unary retry pipeline (Acks are idempotent at MxAccess), maps Unauthenticated/PermissionDenied RpcExceptions to typed MxGatewayAuthenticationException / MxGatewayAuthorizationException via GrpcMxGatewayClientTransport.MapRpcException. - MxGatewayClient.QueryActiveAlarmsAsync — server-streaming IAsyncEnumerable<ActiveAlarmSnapshot> mirroring the StreamEvents pattern. - IMxGatewayClientTransport extended; GrpcMxGatewayClientTransport implements both methods using the regenerated grpc client. - FakeGatewayTransport extended with capture lists, exception queue, and reply / snapshot enqueue helpers. CLI version-string assertions updated for the GatewayProtocolVersion 2 → 3 bump from A.1. The CLI alarms verb (subscribe / acknowledge / query-active) is deferred to a follow-up — keeping this PR focused on the SDK surface that lmxopcua's GalaxyDriver consumes in PR B.2. The other-language SDKs (E.3-E.6) layer the same shape on the regen. Tests: - 6 new MxGatewayClientAlarmsTests — request shape, cancellation honor (linked-token via retry pipeline), Unauthenticated mapping, streaming snapshot enumeration, filter prefix passthrough, cancellation during enumeration. - Full client test suite: 57 passed (was 51; 6 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
521 lines
18 KiB
C#
521 lines
18 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using MxGateway.Client.Cli;
|
|
using MxGateway.Contracts.Proto;
|
|
using MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace MxGateway.Client.Tests;
|
|
|
|
/// <summary>Tests for the CLI command interface.</summary>
|
|
public sealed class MxGatewayClientCliTests
|
|
{
|
|
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
|
|
[Fact]
|
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("gateway-protocol=3", output.ToString());
|
|
Assert.Contains("worker-protocol=1", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Write,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"write",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handle",
|
|
"34",
|
|
"--type",
|
|
"int32",
|
|
"--value",
|
|
"123",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
|
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
|
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"open-session",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"secret-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => throw new InvalidOperationException("boom secret-api-key"));
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.DoesNotContain("secret-api-key", error.ToString());
|
|
Assert.Contains("[redacted]", error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnDataChange,
|
|
WorkerSequence = 1,
|
|
});
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnWriteComplete,
|
|
WorkerSequence = 2,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"stream-events",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--max-events",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("workerSequence", output.ToString());
|
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
|
}
|
|
|
|
|
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
InvokeFailure = new InvalidOperationException("register failed"),
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"smoke",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--item",
|
|
"Area001.Pump001.Speed",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
|
Assert.Equal("session-fixture", closeRequest.SessionId);
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-test-connection",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
|
|
Assert.Contains("\"ok\": true", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "7:1",
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 7,
|
|
TagName = "DelmiaReceiver_001",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "DownloadPath",
|
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 8,
|
|
TagName = "DelmiaReceiver_002",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-discover",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
|
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
|
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
|
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
|
string text = output.ToString();
|
|
Assert.Contains("objects=2", text);
|
|
Assert.Contains("DelmiaReceiver_001", text);
|
|
Assert.Contains("DelmiaReceiver_002", text);
|
|
Assert.Contains("attributes=1", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 1,
|
|
ObservedAt = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 5,
|
|
AttributeCount = 17,
|
|
});
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 2,
|
|
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 6,
|
|
AttributeCount = 18,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--last-seen-deploy-time",
|
|
"2026-04-28T14:00:00Z",
|
|
"--max-events",
|
|
"2",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
|
|
Assert.NotNull(request.LastSeenDeployTime);
|
|
string text = output.ToString();
|
|
Assert.Contains("sequence=1", text);
|
|
Assert.Contains("sequence=2", text);
|
|
Assert.Contains("objects=5", text);
|
|
Assert.Contains("attributes=18", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 42,
|
|
ObjectCount = 99,
|
|
AttributeCount = 1024,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--max-events",
|
|
"1",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("\"sequence\": \"42\"", text);
|
|
Assert.Contains("\"objectCount\": 99", text);
|
|
}
|
|
|
|
/// <summary>Fake CLI client for testing.</summary>
|
|
private sealed class FakeCliClient : IMxGatewayCliClient
|
|
{
|
|
/// <summary>Queue of invoke replies to return.</summary>
|
|
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
|
|
|
/// <summary>List of received invoke requests.</summary>
|
|
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
|
|
|
/// <summary>List of received close session requests.</summary>
|
|
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
|
|
|
/// <summary>List of events to yield when streaming.</summary>
|
|
public List<MxEvent> Events { get; } = [];
|
|
|
|
/// <summary>Exception to throw on invoke, if any.</summary>
|
|
public Exception? InvokeFailure { get; init; }
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<OpenSessionReply> OpenSessionAsync(
|
|
OpenSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new OpenSessionReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
GatewayProtocolVersion = 1,
|
|
WorkerProtocolVersion = 1,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<CloseSessionReply> CloseSessionAsync(
|
|
CloseSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CloseSessionRequests.Add(request);
|
|
return Task.FromResult(new CloseSessionReply
|
|
{
|
|
SessionId = request.SessionId,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
FinalState = SessionState.Closed,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MxCommandReply> InvokeAsync(
|
|
MxCommandRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
InvokeRequests.Add(request);
|
|
if (InvokeFailure is not null)
|
|
{
|
|
throw InvokeFailure;
|
|
}
|
|
|
|
return Task.FromResult(InvokeReplies.Dequeue());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
StreamEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
foreach (MxEvent gatewayEvent in Events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return gatewayEvent;
|
|
}
|
|
}
|
|
|
|
/// <summary>Galaxy test connection reply to return.</summary>
|
|
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
|
|
|
/// <summary>Galaxy get last deploy time reply to return.</summary>
|
|
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
|
|
|
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
|
|
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
|
|
|
/// <summary>List of received galaxy test connection requests.</summary>
|
|
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy get last deploy time requests.</summary>
|
|
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy discover hierarchy requests.</summary>
|
|
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
|
TestConnectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyTestConnectionRequests.Add(request);
|
|
return Task.FromResult(GalaxyTestConnectionReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
|
GetLastDeployTimeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyGetLastDeployTimeRequests.Add(request);
|
|
return Task.FromResult(GalaxyGetLastDeployTimeReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
|
DiscoverHierarchyRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyDiscoverHierarchyRequests.Add(request);
|
|
return Task.FromResult(
|
|
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
|
? reply
|
|
: GalaxyDiscoverHierarchyReply);
|
|
}
|
|
|
|
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
|
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
|
|
|
|
/// <summary>List of deploy events to yield when watching.</summary>
|
|
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
|
WatchDeployEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
GalaxyWatchDeployEventsRequests.Add(request);
|
|
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return deployEvent;
|
|
}
|
|
}
|
|
}
|
|
}
|