deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration
|
||||
{
|
||||
public class ConfigurationValidatorTests
|
||||
{
|
||||
private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration();
|
||||
|
||||
[Fact]
|
||||
public void ValidConfig_PassesValidation()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(70000)]
|
||||
public void InvalidGrpcPort_Throws(int port)
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.GrpcPort = port;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("GrpcPort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidMonitorInterval_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Connection.MonitorIntervalSeconds = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("MonitorIntervalSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelCapacity_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelCapacity = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelCapacity"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelFullMode_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelFullMode = "InvalidMode";
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelFullMode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResetPeriodDays_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.ResetPeriodDays = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ResetPeriodDays"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeFailureDelay_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.FirstFailureDelayMinutes = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("FirstFailureDelayMinutes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
|
||||
{
|
||||
public class QualityCodeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Quality.Good, 0x00000000u, "Good")]
|
||||
[InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")]
|
||||
[InlineData(Quality.Bad, 0x80000000u, "Bad")]
|
||||
[InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")]
|
||||
[InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")]
|
||||
[InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")]
|
||||
[InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")]
|
||||
[InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")]
|
||||
[InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")]
|
||||
[InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")]
|
||||
[InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")]
|
||||
[InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")]
|
||||
[InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")]
|
||||
[InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")]
|
||||
public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName)
|
||||
{
|
||||
var qc = QualityCodeMapper.ToQualityCode(quality);
|
||||
qc.StatusCode.Should().Be(expectedStatusCode);
|
||||
qc.SymbolicName.Should().Be(expectedName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, Quality.Good)]
|
||||
[InlineData(0x80000000u, Quality.Bad)]
|
||||
[InlineData(0x80040000u, Quality.Bad_ConfigError)]
|
||||
[InlineData(0x806D0000u, Quality.Bad_SensorFailure)]
|
||||
[InlineData(0x40900000u, Quality.Uncertain_LastUsable)]
|
||||
public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality)
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownGoodCode_FallsBackToGood()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownBadCode_FallsBackToBad()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, "Good")]
|
||||
[InlineData(0x80000000u, "Bad")]
|
||||
[InlineData(0x806D0000u, "BadSensorFailure")]
|
||||
[InlineData(0x40900000u, "UncertainLastUsableValue")]
|
||||
[InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back
|
||||
public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName)
|
||||
{
|
||||
QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactoryMethods_ReturnCorrectCodes()
|
||||
{
|
||||
QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u);
|
||||
QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u);
|
||||
QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u);
|
||||
QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u);
|
||||
QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u);
|
||||
QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u);
|
||||
QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u);
|
||||
QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u);
|
||||
QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u);
|
||||
QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u);
|
||||
QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
|
||||
{
|
||||
public class QualityExtensionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Quality.Good, true)]
|
||||
[InlineData(Quality.Good_LocalOverride, true)]
|
||||
[InlineData(Quality.Uncertain, false)]
|
||||
[InlineData(Quality.Bad, false)]
|
||||
public void IsGood(Quality q, bool expected)
|
||||
{
|
||||
q.IsGood().Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Quality.Uncertain, true)]
|
||||
[InlineData(Quality.Uncertain_LastUsable, true)]
|
||||
[InlineData(Quality.Good, false)]
|
||||
[InlineData(Quality.Bad, false)]
|
||||
public void IsUncertain(Quality q, bool expected)
|
||||
{
|
||||
q.IsUncertain().Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Quality.Bad, true)]
|
||||
[InlineData(Quality.Bad_CommFailure, true)]
|
||||
[InlineData(Quality.Good, false)]
|
||||
[InlineData(Quality.Uncertain, false)]
|
||||
public void IsBad(Quality q, bool expected)
|
||||
{
|
||||
q.IsBad().Should().Be(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
|
||||
{
|
||||
public class TypedValueConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Null_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(null);
|
||||
tv.Should().BeNull();
|
||||
TypedValueConverter.FromTypedValue(null).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DBNull_MapsToNull()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(DBNull.Value);
|
||||
tv.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bool_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(true);
|
||||
tv.Should().NotBeNull();
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue);
|
||||
tv.BoolValue.Should().BeTrue();
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(true);
|
||||
|
||||
var tvFalse = TypedValueConverter.ToTypedValue(false);
|
||||
tvFalse!.BoolValue.Should().BeFalse();
|
||||
TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_WidensToInt32()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((short)42);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(42);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(int.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(int.MaxValue);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(long.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(long.MaxValue);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UShort_WidensToInt32()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((ushort)65535);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(65535);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UInt_WidensToInt64()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(uint.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(uint.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ULong_MapsToInt64()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((ulong)12345678);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(3.14159f);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue);
|
||||
tv.FloatValue.Should().Be(3.14159f);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(2.718281828459045);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
|
||||
tv.DoubleValue.Should().Be(2.718281828459045);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue("Hello World");
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
|
||||
tv.StringValue.Should().Be("Hello World");
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_RoundTrips_AsUtcTicks()
|
||||
{
|
||||
var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tv = TypedValueConverter.ToTypedValue(dt);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue);
|
||||
tv.DatetimeValue.Should().Be(dt.Ticks);
|
||||
var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Kind.Should().Be(DateTimeKind.Utc);
|
||||
result.Ticks.Should().Be(dt.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteArray_RoundTrips()
|
||||
{
|
||||
var bytes = new byte[] { 0x00, 0xFF, 0x42 };
|
||||
var tv = TypedValueConverter.ToTypedValue(bytes);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue);
|
||||
var result = (byte[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decimal_MapsToDouble()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(123.456m);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
|
||||
tv.DoubleValue.Should().BeApproximately(123.456, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatArray_RoundTrips()
|
||||
{
|
||||
var arr = new float[] { 1.0f, 2.0f, 3.0f };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (float[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntArray_RoundTrips()
|
||||
{
|
||||
var arr = new int[] { 10, 20, 30 };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (int[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringArray_RoundTrips()
|
||||
{
|
||||
var arr = new string[] { "a", "b", "c" };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (string[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleArray_RoundTrips()
|
||||
{
|
||||
var arr = new double[] { 1.1, 2.2, 3.3 };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (double[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrecognizedType_FallsBackToString()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
var tv = TypedValueConverter.ToTypedValue(guid);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
|
||||
tv.StringValue.Should().Be(guid.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health
|
||||
{
|
||||
public class HealthCheckServiceTests
|
||||
{
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true, ConnectionState = ConnectionState.Connected };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsUnhealthy_WhenNotConnected()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = false, ConnectionState = ConnectionState.Disconnected };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
result.Description.Should().Contain("not connected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Record 200 operations with 40% success rate
|
||||
for (int i = 0; i < 80; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
for (int i = 0; i < 120; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Degraded);
|
||||
result.Description.Should().Contain("success rate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenClientCountOver100()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Create 101 subscriptions to exceed the threshold
|
||||
for (int i = 0; i < 101; i++)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await sm.SubscribeAsync("client-" + i, new[] { "tag1" }, cts.Token);
|
||||
}
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Degraded);
|
||||
result.Description.Should().Contain("client count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotFlagLowSuccessRate_Under100Operations()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Record 50 operations with 0% success rate (under 100 threshold)
|
||||
for (int i = 0; i < 50; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics
|
||||
{
|
||||
public class PerformanceMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordOperation_TracksCountAndDuration()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Should().ContainKey("TestOp");
|
||||
stats["TestOp"].TotalCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordOperation_TracksSuccessAndFailure()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
|
||||
}
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessRate.Should().BeApproximately(0.6, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_CalculatesP95Correctly()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].Percentile95Milliseconds.Should().BeApproximately(95.0, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollingBuffer_CapsAt1000Samples()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 1500; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
// TotalCount tracks all 1500 but percentile is computed from the last 1000
|
||||
stats["TestOp"].TotalCount.Should().Be(1500);
|
||||
// The rolling buffer should have entries from 500-1499
|
||||
// P95 of 500..1499 should be around 1449
|
||||
stats["TestOp"].Percentile95Milliseconds.Should().BeGreaterThan(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_RecordsDurationOnDispose()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
System.Threading.Thread.Sleep(50);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Should().ContainKey("TestOp");
|
||||
stats["TestOp"].TotalCount.Should().Be(1);
|
||||
stats["TestOp"].AverageMilliseconds.Should().BeGreaterOrEqualTo(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_DefaultsToSuccess()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
// Do nothing — default is success
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_RespectsSetSuccessFalse()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessCount.Should().Be(0);
|
||||
stats["TestOp"].TotalCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMetrics_ReturnsNullForUnknownOperation()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
var result = metrics.GetMetrics("DoesNotExist");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllMetrics_ReturnsAllTrackedOperations()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20), true);
|
||||
metrics.RecordOperation("Subscribe", TimeSpan.FromMilliseconds(5), true);
|
||||
|
||||
var all = metrics.GetAllMetrics();
|
||||
all.Should().ContainKey("Read");
|
||||
all.Should().ContainKey("Write");
|
||||
all.Should().ContainKey("Subscribe");
|
||||
all.Count.Should().Be(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess
|
||||
{
|
||||
public class TypedValueEqualsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void String_CaseSensitive()
|
||||
{
|
||||
TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue();
|
||||
TypedValueComparer.Equals("done", "DONE").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_SameElements()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_DifferentElements()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_DifferentLengths()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32_NotEqual_ToDouble()
|
||||
{
|
||||
TypedValueComparer.Equals(1, 1.0).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_Equality()
|
||||
{
|
||||
TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_TickPrecision()
|
||||
{
|
||||
var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc);
|
||||
var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc);
|
||||
TypedValueComparer.Equals(dt1, dt2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyInterceptorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/scada.ScadaService/Write")]
|
||||
[InlineData("/scada.ScadaService/WriteBatch")]
|
||||
[InlineData("/scada.ScadaService/WriteBatchAndWait")]
|
||||
public void WriteProtectedMethods_AreCorrectlyDefined(string method)
|
||||
{
|
||||
// This test verifies the set of write-protected methods is correct.
|
||||
// The actual interceptor logic is tested via integration tests.
|
||||
var writeProtected = new System.Collections.Generic.HashSet<string>(
|
||||
System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/scada.ScadaService/Write",
|
||||
"/scada.ScadaService/WriteBatch",
|
||||
"/scada.ScadaService/WriteBatchAndWait"
|
||||
};
|
||||
writeProtected.Should().Contain(method);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/scada.ScadaService/Connect")]
|
||||
[InlineData("/scada.ScadaService/Disconnect")]
|
||||
[InlineData("/scada.ScadaService/GetConnectionState")]
|
||||
[InlineData("/scada.ScadaService/Read")]
|
||||
[InlineData("/scada.ScadaService/ReadBatch")]
|
||||
[InlineData("/scada.ScadaService/Subscribe")]
|
||||
[InlineData("/scada.ScadaService/CheckApiKey")]
|
||||
public void ReadMethods_AreNotWriteProtected(string method)
|
||||
{
|
||||
var writeProtected = new System.Collections.Generic.HashSet<string>(
|
||||
System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/scada.ScadaService/Write",
|
||||
"/scada.ScadaService/WriteBatch",
|
||||
"/scada.ScadaService/WriteBatchAndWait"
|
||||
};
|
||||
writeProtected.Should().NotContain(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ApiKeyServiceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
private string CreateKeyFile(params ApiKey[] keys)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "apikeys.json");
|
||||
var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List<ApiKey>(keys) };
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented));
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoGeneratesDefaultFile_WhenMissing()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "missing.json");
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
File.Exists(path).Should().BeTrue();
|
||||
svc.KeyCount.Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsKey_WhenValid()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
var key = svc.ValidateApiKey("test-key");
|
||||
key.Should().NotBeNull();
|
||||
key!.Role.Should().Be(ApiKeyRole.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsNull_WhenInvalid()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("wrong-key").Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsNull_WhenDisabled()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("test-key").Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadWrite_CanRead()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadOnly_CannotWrite()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadWrite_CanWrite()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_EmptyString_ReturnsNull()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("").Should().BeNull();
|
||||
svc.ValidateApiKey(null!).Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions
|
||||
{
|
||||
public class SessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSession_Returns32CharHexId()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("client1", "key1");
|
||||
id.Should().HaveLength(32);
|
||||
id.Should().MatchRegex("^[0-9a-f]{32}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSession_IncrementsCount()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.ActiveSessionCount.Should().Be(1);
|
||||
sm.CreateSession("c2", "k2");
|
||||
sm.ActiveSessionCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_ReturnsTrueForExistingSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
sm.ValidateSession(id).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_ReturnsFalseForUnknownSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.ValidateSession("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_UpdatesLastActivity()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
var session = sm.GetSession(id);
|
||||
var initialActivity = session!.LastActivity;
|
||||
|
||||
Thread.Sleep(50); // Small delay to ensure time passes
|
||||
sm.ValidateSession(id);
|
||||
|
||||
session.LastActivity.Should().BeAfter(initialActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminateSession_RemovesSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
sm.TerminateSession(id).Should().BeTrue();
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
sm.ValidateSession(id).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminateSession_ReturnsFalseForUnknownSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.TerminateSession("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSession_ReturnsNullForUnknown()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.GetSession("nonexistent").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSession_ReturnsCorrectInfo()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("client-abc", "key-xyz");
|
||||
var session = sm.GetSession(id);
|
||||
session.Should().NotBeNull();
|
||||
session!.ClientId.Should().Be("client-abc");
|
||||
session.ApiKey.Should().Be("key-xyz");
|
||||
session.SessionId.Should().Be(id);
|
||||
session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllSessions_ReturnsSnapshot()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.CreateSession("c2", "k2");
|
||||
var all = sm.GetAllSessions();
|
||||
all.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_IsThreadSafe()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var tasks = new Task[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
int idx = i;
|
||||
tasks[i] = Task.Run(() =>
|
||||
{
|
||||
var id = sm.CreateSession($"client-{idx}", $"key-{idx}");
|
||||
sm.ValidateSession(id);
|
||||
if (idx % 3 == 0) sm.TerminateSession(id);
|
||||
});
|
||||
}
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Should have ~67 sessions remaining (100 - ~33 terminated)
|
||||
sm.ActiveSessionCount.Should().BeInRange(60, 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ClearsAllSessions()
|
||||
{
|
||||
var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.CreateSession("c2", "k2");
|
||||
sm.Dispose();
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectedSinceUtcTicks_ReturnsCorrectValue()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
var session = sm.GetSession(id);
|
||||
session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Status;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status
|
||||
{
|
||||
public class StatusReportServiceTests
|
||||
{
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; }
|
||||
}
|
||||
|
||||
private (StatusReportService svc, PerformanceMetrics pm, SubscriptionManager sm) CreateService(
|
||||
bool connected = true)
|
||||
{
|
||||
var client = new FakeScadaClient
|
||||
{
|
||||
IsConnected = connected,
|
||||
ConnectionState = connected ? ConnectionState.Connected : ConnectionState.Disconnected
|
||||
};
|
||||
var sm = new SubscriptionManager(client);
|
||||
var pm = new PerformanceMetrics();
|
||||
var health = new HealthCheckService(client, sm, pm);
|
||||
var svc = new StatusReportService(client, sm, pm, health);
|
||||
return (svc, pm, sm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var json = await svc.GenerateJsonReportAsync();
|
||||
|
||||
json.Should().Contain("\"serviceName\"");
|
||||
json.Should().Contain("\"connection\"");
|
||||
json.Should().Contain("\"isConnected\"");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateHtmlReportAsync_ContainsAutoRefresh()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var html = await svc.GenerateHtmlReportAsync();
|
||||
|
||||
html.Should().Contain("<meta http-equiv=\"refresh\" content=\"30\">");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsTrueWhenHealthy()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService(connected: true);
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var result = await svc.IsHealthyAsync();
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService(connected: false);
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var result = await svc.IsHealthyAsync();
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(15), true);
|
||||
pm.RecordOperation("Write", TimeSpan.FromMilliseconds(25), true);
|
||||
|
||||
var json = await svc.GenerateJsonReportAsync();
|
||||
var parsed = JObject.Parse(json);
|
||||
|
||||
var operations = parsed["performance"]?["operations"];
|
||||
operations.Should().NotBeNull();
|
||||
// Newtonsoft CamelCasePropertyNamesContractResolver camelCases dictionary keys
|
||||
operations!["read"].Should().NotBeNull();
|
||||
operations!["write"].Should().NotBeNull();
|
||||
((long)operations!["read"]!["totalCount"]!).Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions
|
||||
{
|
||||
public class SubscriptionManagerTests
|
||||
{
|
||||
/// <summary>Fake IScadaClient for testing (no COM dependency).</summary>
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected => true;
|
||||
public ConnectionState ConnectionState => ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeSubscriptionHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
// Suppress unused event warning
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
|
||||
private class FakeSubscriptionHandle : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync() => default;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_ReturnsChannelReader()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
reader.Should().NotBeNull();
|
||||
subscriptionId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_FansOutToSubscribedClients()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
var vtq = Vtq.Good(42.0);
|
||||
sm.OnTagValueChanged("Motor.Speed", vtq);
|
||||
|
||||
var result = await reader.ReadAsync(cts.Token);
|
||||
result.address.Should().Be("Motor.Speed");
|
||||
result.vtq.Value.Should().Be(42.0);
|
||||
result.vtq.Quality.Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_MultipleClients_BothReceive()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader1, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
var (reader2, _) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0));
|
||||
|
||||
var r1 = await reader1.ReadAsync(cts.Token);
|
||||
var r2 = await reader2.ReadAsync(cts.Token);
|
||||
r1.vtq.Value.Should().Be(99.0);
|
||||
r2.vtq.Value.Should().Be(99.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0));
|
||||
|
||||
// Channel should be empty
|
||||
reader.TryRead(out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeSubscription_CompletesChannel()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeSubscription(subscriptionId);
|
||||
|
||||
// Channel should be completed
|
||||
reader.Completion.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeSession_RemovesAllSubscriptions()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeSession("client1");
|
||||
|
||||
var stats = sm.GetStats();
|
||||
stats.TotalClients.Should().Be(0);
|
||||
stats.TotalTags.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefCounting_LastSubscriptionUnsubscribeRemovesTag()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (_, subId1) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
var (_, subId2) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.GetStats().TotalTags.Should().Be(1);
|
||||
|
||||
sm.UnsubscribeSubscription(subId1);
|
||||
sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed
|
||||
|
||||
sm.UnsubscribeSubscription(subId2);
|
||||
sm.GetStats().TotalTags.Should().Be(0); // last subscription gone
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDisconnection_SendsBadQualityToAll()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token);
|
||||
|
||||
sm.NotifyDisconnection();
|
||||
|
||||
// Should receive 2 bad quality messages
|
||||
reader.TryRead(out var r1).Should().BeTrue();
|
||||
r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
|
||||
reader.TryRead(out var r2).Should().BeTrue();
|
||||
r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backpressure_DropOldest_DropsWhenFull()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3);
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
// Fill the channel beyond capacity
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i));
|
||||
}
|
||||
|
||||
// Should have exactly 3 messages (capacity limit)
|
||||
int count = 0;
|
||||
while (reader.TryRead(out _)) count++;
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (_, _) = await sm.SubscribeAsync("c1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
var (_, _) = await sm.SubscribeAsync("c2", new[] { "Tag2", "Tag3" }, cts.Token);
|
||||
|
||||
var stats = sm.GetStats();
|
||||
stats.TotalClients.Should().Be(2);
|
||||
stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3
|
||||
stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.LmxProxy.Host.Tests</RootNamespace>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Platforms>x86</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user