Files
scadalink-design/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs
Joseph Doherty 5ec7f35150 feat(dcl): replace hand-rolled LmxProxy gRPC client with real LmxProxyClient library
Switches from v1 string-based proto stubs to the production LmxProxyClient
(v2 native TypedValue protocol) via project reference. Deletes 6k+ lines of
generated proto code. Preserves ILmxProxyClient adapter interface for testability.
2026-03-22 07:55:50 -04:00

310 lines
9.7 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.DataConnectionLayer.Adapters;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using QualityCode = ScadaLink.Commons.Interfaces.Protocol.QualityCode;
namespace ScadaLink.DataConnectionLayer.Tests;
public class LmxProxyDataConnectionTests
{
private readonly ILmxProxyClient _mockClient;
private readonly ILmxProxyClientFactory _mockFactory;
private readonly LmxProxyDataConnection _adapter;
public LmxProxyDataConnectionTests()
{
_mockClient = Substitute.For<ILmxProxyClient>();
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
_mockFactory.Create(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<bool>()).Returns(_mockClient);
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.Instance);
}
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.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>
{
["Host"] = "myhost",
["Port"] = "5001"
});
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
_mockFactory.Received(1).Create("myhost", 5001, null, false);
await _mockClient.Received(1).ConnectAsync(Arg.Any<CancellationToken>());
}
[Fact]
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", false);
}
[Fact]
public async Task Connect_DefaultsHostAndPort()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
_mockFactory.Received(1).Create("localhost", 50051, null, false);
}
[Fact]
public async Task Disconnect_SetsStatusToDisconnected()
{
await ConnectAdapter();
await _adapter.DisconnectAsync();
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
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 Vtq(42.5, now, Quality.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 Vtq(null, DateTime.UtcNow, Quality.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 Vtq("maybe", DateTime.UtcNow, Quality.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, Vtq>
{
["Tag1"] = new(10, now, Quality.Good),
["Tag2"] = new(null, now, Quality.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()
{
await ConnectAdapter();
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.True(result.Success);
await _mockClient.Received(1).WriteAsync("Tag1", Arg.Any<TypedValue>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Write_Failure_ReturnsError()
{
await ConnectAdapter();
_mockClient.WriteAsync("Tag1", Arg.Any<TypedValue>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("Write failed for tag"));
var result = await _adapter.WriteAsync("Tag1", 42);
Assert.False(result.Success);
Assert.Contains("Write failed for tag", result.ErrorMessage);
}
[Fact]
public async Task WriteBatch_Success_ReturnsAllGood()
{
await ConnectAdapter();
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["T1"] = 1, ["T2"] = 2 });
Assert.True(results["T1"].Success);
Assert.True(results["T2"].Success);
}
[Fact]
public async Task WriteBatch_Failure_ReturnsAllErrors()
{
await ConnectAdapter();
_mockClient.WriteBatchAsync(Arg.Any<IDictionary<string, TypedValue>>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("Batch write failed"));
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?> { ["T1"] = 1, ["T2"] = 2 });
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, Vtq>>(), Arg.Any<Action<Exception>?>(), 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, Vtq>>(), Arg.Any<Action<Exception>?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Unsubscribe_DisposesSubscription()
{
await ConnectAdapter();
var mockSub = Substitute.For<ILmxSubscription>();
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, Vtq>>(), Arg.Any<Action<Exception>?>(), 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, Vtq>>(), Arg.Any<Action<Exception>?>(), 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.SubscribeAsync("tag1", (_, _) => { }));
}
// --- Configuration Parsing ---
[Fact]
public async Task Connect_ParsesUseTls()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>
{
["Host"] = "server",
["Port"] = "50051",
["UseTls"] = "true"
});
_mockFactory.Received(1).Create("server", 50051, null, true);
}
[Fact]
public async Task Connect_DefaultsHostPortAndTls()
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
_mockFactory.Received(1).Create("localhost", 50051, null, false);
}
}