diff --git a/lmxproxy/ZB.MOM.WW.LmxProxy.slnx b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx
index 4f43016..ca75a8e 100644
--- a/lmxproxy/ZB.MOM.WW.LmxProxy.slnx
+++ b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx
@@ -6,5 +6,6 @@
+
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs
new file mode 100644
index 0000000..b5095e3
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs
@@ -0,0 +1,25 @@
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class CheckApiKeyTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task CheckApiKey_ValidReadWrite_ReturnsValid()
+ {
+ var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey);
+ Assert.True(info.IsValid);
+ }
+
+ [Fact]
+ public async Task CheckApiKey_ValidReadOnly_ReturnsValid()
+ {
+ var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey);
+ Assert.True(info.IsValid);
+ }
+
+ [Fact]
+ public async Task CheckApiKey_Invalid_ReturnsInvalid()
+ {
+ var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345");
+ Assert.False(info.IsValid);
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs
new file mode 100644
index 0000000..e0622e6
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs
@@ -0,0 +1,29 @@
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class ConnectionTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task ConnectAndDisconnect_Succeeds()
+ {
+ // Client is connected in InitializeAsync
+ Assert.True(await Client!.IsConnectedAsync());
+ await Client.DisconnectAsync();
+ Assert.False(await Client.IsConnectedAsync());
+ }
+
+ [Fact]
+ public async Task ConnectWithInvalidApiKey_Fails()
+ {
+ using var badClient = CreateClient(InvalidApiKey);
+ var ex = await Assert.ThrowsAsync(
+ () => badClient.ConnectAsync());
+ Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode);
+ }
+
+ [Fact]
+ public async Task DoubleConnect_IsIdempotent()
+ {
+ await Client!.ConnectAsync(); // Already connected — should be no-op
+ Assert.True(await Client.IsConnectedAsync());
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs
new file mode 100644
index 0000000..d2a23ce
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using Xunit;
+global using ZB.MOM.WW.LmxProxy.Client.Domain;
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs
new file mode 100644
index 0000000..d11ee7b
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs
@@ -0,0 +1,56 @@
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.LmxProxy.Client;
+
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public abstract class IntegrationTestBase : IAsyncLifetime
+{
+ protected IConfiguration Configuration { get; }
+ protected string Host { get; }
+ protected int Port { get; }
+ protected string ReadWriteApiKey { get; }
+ protected string ReadOnlyApiKey { get; }
+ protected string InvalidApiKey { get; }
+ protected LmxProxyClient? Client { get; set; }
+
+ protected IntegrationTestBase()
+ {
+ Configuration = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.test.json")
+ .Build();
+
+ var section = Configuration.GetSection("LmxProxy");
+ Host = section["Host"] ?? "10.100.0.48";
+ Port = int.Parse(section["Port"] ?? "50052");
+ ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured");
+ ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured");
+ InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key";
+ }
+
+ protected LmxProxyClient CreateClient(string? apiKey = null)
+ {
+ return new LmxProxyClientBuilder()
+ .WithHost(Host)
+ .WithPort(Port)
+ .WithApiKey(apiKey ?? ReadWriteApiKey)
+ .WithTimeout(TimeSpan.FromSeconds(10))
+ .WithRetryPolicy(2, TimeSpan.FromSeconds(1))
+ .WithMetrics()
+ .Build();
+ }
+
+ public virtual async Task InitializeAsync()
+ {
+ Client = CreateClient();
+ await Client.ConnectAsync();
+ }
+
+ public virtual async Task DisposeAsync()
+ {
+ if (Client is not null)
+ {
+ await Client.DisconnectAsync();
+ Client.Dispose();
+ }
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs
new file mode 100644
index 0000000..9047e71
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs
@@ -0,0 +1,61 @@
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class ReadTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task Read_BoolTag_ReturnsBoolValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestBool");
+ Assert.IsType(vtq.Value);
+ Assert.True(vtq.Quality.IsGood());
+ }
+
+ [Fact]
+ public async Task Read_IntTag_ReturnsIntValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestInt");
+ Assert.True(vtq.Value is int or long);
+ Assert.True(vtq.Quality.IsGood());
+ }
+
+ [Fact]
+ public async Task Read_FloatTag_ReturnsFloatValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestFloat");
+ Assert.True(vtq.Value is float or double);
+ Assert.True(vtq.Quality.IsGood());
+ }
+
+ [Fact]
+ public async Task Read_DoubleTag_ReturnsDoubleValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestDouble");
+ Assert.IsType(vtq.Value);
+ Assert.True(vtq.Quality.IsGood());
+ }
+
+ [Fact]
+ public async Task Read_StringTag_ReturnsStringValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestString");
+ Assert.IsType(vtq.Value);
+ Assert.True(vtq.Quality.IsGood());
+ }
+
+ [Fact]
+ public async Task Read_DateTimeTag_ReturnsDateTimeValue()
+ {
+ var vtq = await Client!.ReadAsync("TestChildObject.TestDateTime");
+ Assert.IsType(vtq.Value);
+ Assert.True(vtq.Quality.IsGood());
+ Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1));
+ }
+
+ [Fact]
+ public async Task ReadBatch_MultipleTags_ReturnsDictionary()
+ {
+ var tags = new[] { "TestChildObject.TestString", "TestChildObject.TestInt" };
+ var results = await Client!.ReadBatchAsync(tags);
+ Assert.Equal(2, results.Count);
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs
new file mode 100644
index 0000000..ed2d307
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs
@@ -0,0 +1,34 @@
+using ZB.MOM.WW.LmxProxy.Client.Domain;
+
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class SubscribeTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task Subscribe_ReceivesUpdates()
+ {
+ var received = new List<(string Tag, Vtq Vtq)>();
+ var receivedEvent = new TaskCompletionSource();
+
+ var subscription = await Client!.SubscribeAsync(
+ new[] { "TestChildObject.TestInt" },
+ (tag, vtq) =>
+ {
+ received.Add((tag, vtq));
+ if (received.Count >= 3)
+ receivedEvent.TrySetResult(true);
+ },
+ ex => receivedEvent.TrySetException(ex));
+
+ // Wait up to 30 seconds for at least 3 updates
+ var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30)));
+ subscription.Dispose();
+
+ Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}");
+
+ var first = received[0];
+ Assert.Equal("TestChildObject.TestInt", first.Tag);
+ Assert.NotNull(first.Vtq.Value);
+ Assert.True(first.Vtq.Timestamp > DateTime.MinValue);
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs
new file mode 100644
index 0000000..64f6405
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs
@@ -0,0 +1,26 @@
+using ZB.MOM.WW.LmxProxy.Client.Domain;
+
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class WriteBatchAndWaitTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task WriteBatchAndWait_TypeAwareComparison()
+ {
+ var values = new Dictionary
+ {
+ ["TestChildObject.TestString"] = new TypedValue { StringValue = "BatchTest" }
+ };
+
+ var response = await Client!.WriteBatchAndWaitAsync(
+ values,
+ flagTag: "TestChildObject.TestString",
+ flagValue: new TypedValue { StringValue = "BatchTest" },
+ timeoutMs: 5000,
+ pollIntervalMs: 200);
+
+ Assert.True(response.Success);
+ Assert.True(response.FlagReached);
+ Assert.True(response.ElapsedMs < 5000);
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs
new file mode 100644
index 0000000..38d34e9
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs
@@ -0,0 +1,30 @@
+using ZB.MOM.WW.LmxProxy.Client.Domain;
+
+namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
+
+public class WriteTests : IntegrationTestBase
+{
+ [Fact]
+ public async Task WriteAndReadBack_StringValue()
+ {
+ string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}";
+ await Client!.WriteAsync("TestChildObject.TestString",
+ new TypedValue { StringValue = testValue });
+
+ await Task.Delay(500); // Allow time for write to propagate
+ var vtq = await Client.ReadAsync("TestChildObject.TestString");
+ Assert.Equal(testValue, vtq.Value);
+ }
+
+ [Fact]
+ public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied()
+ {
+ using var readOnlyClient = CreateClient(ReadOnlyApiKey);
+ await readOnlyClient.ConnectAsync();
+
+ var ex = await Assert.ThrowsAsync(
+ () => readOnlyClient.WriteAsync("TestChildObject.TestString",
+ new TypedValue { StringValue = "should-fail" }));
+ Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode);
+ }
+}
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj
new file mode 100644
index 0000000..7799e60
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json
new file mode 100644
index 0000000..8782d8f
--- /dev/null
+++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json
@@ -0,0 +1,9 @@
+{
+ "LmxProxy": {
+ "Host": "10.100.0.48",
+ "Port": 50052,
+ "ReadWriteApiKey": "REPLACE_WITH_ACTUAL_KEY",
+ "ReadOnlyApiKey": "REPLACE_WITH_ACTUAL_KEY",
+ "InvalidApiKey": "invalid-key-that-does-not-exist"
+ }
+}