feat: wire real LmxProxy gRPC client into Data Connection Layer
Replace stub ILmxProxyClient with production proto-generated gRPC client (RealLmxProxyClient) that connects to LmxProxy servers with x-api-key metadata header authentication. Includes pre-generated proto stubs for ARM64 Docker compatibility, updated adapter with proper quality mapping (Good/Uncertain/Bad), subscription via server-streaming RPC, and 20 unit tests covering all operations. Updated Component-DataConnectionLayer.md to reflect the actual implementation.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Tests for LmxProxy adapter.
|
||||
/// </summary>
|
||||
public class LmxProxyDataConnectionTests
|
||||
{
|
||||
private readonly ILmxProxyClient _mockClient;
|
||||
@@ -18,15 +17,21 @@ public class LmxProxyDataConnectionTests
|
||||
{
|
||||
_mockClient = Substitute.For<ILmxProxyClient>();
|
||||
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
|
||||
_mockFactory.Create().Returns(_mockClient);
|
||||
_mockFactory.Create(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string?>()).Returns(_mockClient);
|
||||
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_OpensSessionWithHostAndPort()
|
||||
private async Task ConnectAdapter(Dictionary<string, string>? details = null)
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(details ?? new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
// --- Connection ---
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_SetsStatusToConnected()
|
||||
{
|
||||
_mockClient.OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
@@ -36,91 +41,240 @@ public class LmxProxyDataConnectionTests
|
||||
});
|
||||
|
||||
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
|
||||
await _mockClient.Received(1).OpenSessionAsync("myhost", 5001, Arg.Any<CancellationToken>());
|
||||
_mockFactory.Received(1).Create("myhost", 5001, null);
|
||||
await _mockClient.Received(1).ConnectAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_ClosesSession()
|
||||
public async Task Connect_ExtractsApiKeyFromDetails()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["Host"] = "server",
|
||||
["Port"] = "50051",
|
||||
["ApiKey"] = "my-secret-key"
|
||||
});
|
||||
|
||||
_mockFactory.Received(1).Create("server", 50051, "my-secret-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_DefaultsHostAndPort()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
_mockFactory.Received(1).Create("localhost", 50051, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_SetsStatusToDisconnected()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
await _adapter.DisconnectAsync();
|
||||
|
||||
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
|
||||
await _mockClient.Received(1).CloseSessionAsync(Arg.Any<CancellationToken>());
|
||||
await _mockClient.Received(1).DisconnectAsync();
|
||||
}
|
||||
|
||||
// --- Read ---
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Good_ReturnsSuccessWithValue()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var now = DateTime.UtcNow;
|
||||
_mockClient.ReadAsync("Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns(new LmxVtq(42.5, now, LmxQuality.Good));
|
||||
|
||||
var result = await _adapter.ReadAsync("Tag1");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(42.5, result.Value!.Value);
|
||||
Assert.Equal(QualityCode.Good, result.Value.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Bad_ReturnsFailureWithValue()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
_mockClient.ReadAsync("Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns(new LmxVtq(null, DateTime.UtcNow, LmxQuality.Bad));
|
||||
|
||||
var result = await _adapter.ReadAsync("Tag1");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(QualityCode.Bad, result.Value!.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Uncertain_MapsQuality()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
_mockClient.ReadAsync("Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns(new LmxVtq("maybe", DateTime.UtcNow, LmxQuality.Uncertain));
|
||||
|
||||
var result = await _adapter.ReadAsync("Tag1");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(QualityCode.Uncertain, result.Value!.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatch_ReturnsMappedResults()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var now = DateTime.UtcNow;
|
||||
_mockClient.ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, LmxVtq>
|
||||
{
|
||||
["Tag1"] = new(10, now, LmxQuality.Good),
|
||||
["Tag2"] = new(null, now, LmxQuality.Bad)
|
||||
});
|
||||
|
||||
var results = await _adapter.ReadBatchAsync(["Tag1", "Tag2"]);
|
||||
|
||||
Assert.True(results["Tag1"].Success);
|
||||
Assert.Equal(10, results["Tag1"].Value!.Value);
|
||||
Assert.False(results["Tag2"].Success);
|
||||
}
|
||||
|
||||
// --- Write ---
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Success_ReturnsGoodResult()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
await ConnectAdapter();
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.WriteAsync("Tag1", 42);
|
||||
|
||||
Assert.True(result.Success);
|
||||
await _mockClient.Received(1).WriteAsync("Tag1", 42, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Failure_ReturnsError()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
_mockClient.WriteTagAsync("Tag1", 42, Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
await ConnectAdapter();
|
||||
_mockClient.WriteAsync("Tag1", 42, Arg.Any<CancellationToken>())
|
||||
.Throws(new InvalidOperationException("Write failed for tag"));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.WriteAsync("Tag1", 42);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("LmxProxy write failed", result.ErrorMessage);
|
||||
Assert.Contains("Write failed for tag", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Good_ReturnsValue()
|
||||
public async Task WriteBatch_Success_ReturnsAllGood()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns((42.5, DateTime.UtcNow, true));
|
||||
await ConnectAdapter();
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.ReadAsync("Tag1");
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["T1"] = 1, ["T2"] = 2 });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(42.5, result.Value!.Value);
|
||||
Assert.True(results["T1"].Success);
|
||||
Assert.True(results["T2"].Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Bad_ReturnsFailure()
|
||||
public async Task WriteBatch_Failure_ReturnsAllErrors()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.OpenSessionAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns("session-123");
|
||||
_mockClient.ReadTagAsync("Tag1", Arg.Any<CancellationToken>())
|
||||
.Returns((null, DateTime.UtcNow, false));
|
||||
await ConnectAdapter();
|
||||
_mockClient.WriteBatchAsync(Arg.Any<IDictionary<string, object>>(), Arg.Any<CancellationToken>())
|
||||
.Throws(new InvalidOperationException("Batch write failed"));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var result = await _adapter.ReadAsync("Tag1");
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["T1"] = 1, ["T2"] = 2 });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(results["T1"].Success);
|
||||
Assert.False(results["T2"].Success);
|
||||
Assert.Contains("Batch write failed", results["T1"].ErrorMessage);
|
||||
}
|
||||
|
||||
// --- Subscribe ---
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_CreatesSubscriptionAndReturnsId()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var mockSub = Substitute.For<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockSub);
|
||||
|
||||
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||
|
||||
Assert.NotNull(subId);
|
||||
Assert.NotEmpty(subId);
|
||||
await _mockClient.Received(1).SubscribeAsync(
|
||||
Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnOperations()
|
||||
public async Task Unsubscribe_DisposesSubscription()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var mockSub = Substitute.For<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockSub);
|
||||
|
||||
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||
await _adapter.UnsubscribeAsync(subId);
|
||||
|
||||
await mockSub.Received(1).DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_UnknownId_DoesNotThrow()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
await _adapter.UnsubscribeAsync("nonexistent-id");
|
||||
}
|
||||
|
||||
// --- Dispose ---
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_DisposesClientAndSubscriptions()
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var mockSub = Substitute.For<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockSub);
|
||||
await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||
|
||||
await _adapter.DisposeAsync();
|
||||
|
||||
await mockSub.Received(1).DisposeAsync();
|
||||
await _mockClient.Received(1).DisposeAsync();
|
||||
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
|
||||
}
|
||||
|
||||
// --- Guard ---
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnRead()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _adapter.ReadAsync("tag1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnWrite()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _adapter.WriteAsync("tag1", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotConnected_ThrowsOnSubscribe()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_adapter.ReadAsync("tag1"));
|
||||
_adapter.SubscribeAsync("tag1", (_, _) => { }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user