Issue #40: implement .NET values status errors and CLI
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxCommandReplyExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
|
||||
{
|
||||
MxCommandReply reply = ReadReplyFixture("register.ok.reply.json");
|
||||
|
||||
Assert.Same(reply, reply.EnsureProtocolSuccess());
|
||||
Assert.Same(reply, reply.EnsureMxAccessSuccess());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
|
||||
{
|
||||
MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json");
|
||||
|
||||
reply.EnsureProtocolSuccess();
|
||||
MxAccessException exception = Assert.Throws<MxAccessException>(
|
||||
reply.EnsureMxAccessSuccess);
|
||||
|
||||
Assert.Equal(-2147220992, exception.HResultCode);
|
||||
Assert.Equal(reply.Statuses.Count, exception.Statuses.Count);
|
||||
Assert.Equal(reply, exception.Reply);
|
||||
Assert.Contains("0x80040200", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = "session-missing",
|
||||
CorrelationId = "correlation",
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.SessionNotFound,
|
||||
Message = "Session was not found.",
|
||||
},
|
||||
};
|
||||
|
||||
MxGatewaySessionException exception = Assert.Throws<MxGatewaySessionException>(
|
||||
reply.EnsureProtocolSuccess);
|
||||
|
||||
Assert.Equal("session-missing", exception.SessionId);
|
||||
Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code);
|
||||
}
|
||||
|
||||
private static MxCommandReply ReadReplyFixture(string fileName)
|
||||
{
|
||||
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
string path = Path.Combine(
|
||||
directory.FullName,
|
||||
"clients",
|
||||
"proto",
|
||||
"fixtures",
|
||||
"behavior",
|
||||
"command-replies",
|
||||
fileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return JsonParser.Default.Parse<MxCommandReply>(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
directory = directory.Parent!;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(fileName);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MxGateway.Client.Cli;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
@@ -17,4 +18,224 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Contains("worker-protocol=1", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[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\":1", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
private sealed class FakeCliClient : IMxGatewayCliClient
|
||||
{
|
||||
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
||||
|
||||
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
||||
|
||||
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
||||
|
||||
public List<MxEvent> Events { get; } = [];
|
||||
|
||||
public Exception? InvokeFailure { get; init; }
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
public Task<MxCommandReply> InvokeAsync(
|
||||
MxCommandRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
InvokeRequests.Add(request);
|
||||
if (InvokeFailure is not null)
|
||||
{
|
||||
throw InvokeFailure;
|
||||
}
|
||||
|
||||
return Task.FromResult(InvokeReplies.Dequeue());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,33 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(56, request.Command.Write.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Write2,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
MxValue value = 123.ToMxValue();
|
||||
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
|
||||
|
||||
MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56);
|
||||
|
||||
Assert.Equal(MxCommandKind.Write2, reply.Kind);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Write2, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.Write2.ServerHandle);
|
||||
Assert.Equal(34, request.Command.Write2.ItemHandle);
|
||||
Assert.Same(value, request.Command.Write2.Value);
|
||||
Assert.Same(timestampValue, request.Command.Write2.TimestampValue);
|
||||
Assert.Equal(56, request.Command.Write2.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxStatusProxyExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||
"statuses",
|
||||
"status-conversion-cases.json"));
|
||||
|
||||
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||
{
|
||||
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
|
||||
testCase.GetProperty("status").GetRawText());
|
||||
int success = testCase.GetProperty("status").GetProperty("success").GetInt32();
|
||||
|
||||
Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess());
|
||||
Assert.Equal(
|
||||
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
|
||||
status.RawCategory);
|
||||
Assert.Equal(
|
||||
testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(),
|
||||
status.RawDetectedBy);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadFixture(string category, string fileName)
|
||||
{
|
||||
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
string path = Path.Combine(
|
||||
directory.FullName,
|
||||
"clients",
|
||||
"proto",
|
||||
"fixtures",
|
||||
"behavior",
|
||||
category,
|
||||
fileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
directory = directory.Parent!;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxValueExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
|
||||
{
|
||||
Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase);
|
||||
Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase);
|
||||
Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase);
|
||||
Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase);
|
||||
Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase);
|
||||
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
|
||||
{
|
||||
MxValue value = new[] { "alpha", "beta" }.ToMxValue();
|
||||
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
|
||||
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
|
||||
Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values);
|
||||
Assert.Equal([2U], value.ArrayValue.Dimensions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(ReadFixture(
|
||||
"values",
|
||||
"value-conversion-cases.json"));
|
||||
|
||||
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
|
||||
{
|
||||
string expectedKind = testCase.GetProperty("expectedKind").GetString()!;
|
||||
MxValue value = JsonParser.Default.Parse<MxValue>(
|
||||
testCase.GetProperty("value").GetRawText());
|
||||
|
||||
Assert.Equal(expectedKind, value.GetProjectionKind());
|
||||
|
||||
if (testCase.GetProperty("id").GetString() is "raw-fallback.variant")
|
||||
{
|
||||
Assert.Equal(32767, value.RawDataType);
|
||||
Assert.Equal([1, 2, 3, 4, 5], Assert.IsType<byte[]>(value.ToClrValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadFixture(string category, string fileName)
|
||||
{
|
||||
DirectoryInfo directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
string path = Path.Combine(
|
||||
directory.FullName,
|
||||
"clients",
|
||||
"proto",
|
||||
"fixtures",
|
||||
"behavior",
|
||||
category,
|
||||
fileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
directory = directory.Parent!;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user