feat(lmxproxy): phase 6 — client extras (builder, factory, DI, streaming extensions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 00:29:16 -04:00
parent 8ba75b50e8
commit 215cfa29f3
12 changed files with 1082 additions and 3 deletions

View File

@@ -0,0 +1,91 @@
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
/// <summary>
/// Hand-written fake implementation of ILmxProxyClient for unit testing streaming extensions.
/// </summary>
internal class FakeLmxProxyClient : ILmxProxyClient
{
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
// Track calls
public List<List<string>> ReadBatchCalls { get; } = [];
public List<IDictionary<string, TypedValue>> WriteBatchCalls { get; } = [];
public List<IEnumerable<string>> SubscribeCalls { get; } = [];
// Configurable responses
public Func<IEnumerable<string>, CancellationToken, Task<IDictionary<string, Vtq>>>? ReadBatchHandler { get; set; }
public Exception? ReadBatchExceptionToThrow { get; set; }
public int ReadBatchExceptionCount { get; set; }
private int _readBatchCallCount;
// Subscription support
public Action<string, Vtq>? CapturedOnUpdate { get; private set; }
public Action<Exception>? CapturedOnError { get; private set; }
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DisconnectAsync() => Task.CompletedTask;
public Task<bool> IsConnectedAsync() => Task.FromResult(true);
public Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
=> Task.FromResult(new Vtq(null, DateTime.UtcNow, Quality.Good));
public Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
{
var addressList = addresses.ToList();
ReadBatchCalls.Add(addressList);
_readBatchCallCount++;
if (ReadBatchExceptionToThrow is not null && _readBatchCallCount <= ReadBatchExceptionCount)
throw ReadBatchExceptionToThrow;
if (ReadBatchHandler is not null)
return ReadBatchHandler(addressList, cancellationToken);
var result = new Dictionary<string, Vtq>();
foreach (var addr in addressList)
result[addr] = new Vtq(42.0, DateTime.UtcNow, Quality.Good);
return Task.FromResult<IDictionary<string, Vtq>>(result);
}
public Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
{
WriteBatchCalls.Add(new Dictionary<string, TypedValue>(values));
return Task.CompletedTask;
}
public Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
=> Task.FromResult(new WriteBatchAndWaitResponse { Success = true });
public Task<LmxProxyClient.ISubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> onUpdate,
Action<Exception>? onStreamError = null,
CancellationToken cancellationToken = default)
{
SubscribeCalls.Add(addresses);
CapturedOnUpdate = onUpdate;
CapturedOnError = onStreamError;
return Task.FromResult<LmxProxyClient.ISubscription>(new FakeSubscription());
}
public Task<LmxProxyClient.ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
=> Task.FromResult(new LmxProxyClient.ApiKeyInfo { IsValid = true });
public Dictionary<string, object> GetMetrics() => [];
public void Dispose() { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private class FakeSubscription : LmxProxyClient.ISubscription
{
public void Dispose() { }
public Task DisposeAsync() => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class LmxProxyClientBuilderTests
{
[Fact]
public void Build_ThrowsWhenHostNotSet()
{
var builder = new LmxProxyClientBuilder();
Assert.Throws<InvalidOperationException>(() => builder.Build());
}
[Fact]
public void Build_DefaultPort_Is50051()
{
var client = new LmxProxyClientBuilder()
.WithHost("localhost")
.Build();
Assert.NotNull(client);
}
[Fact]
public void WithPort_ThrowsOnZero()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(0));
}
[Fact]
public void WithPort_ThrowsOn65536()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(65536));
}
[Fact]
public void WithTimeout_ThrowsOnNegative()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1)));
}
[Fact]
public void WithTimeout_ThrowsOver10Minutes()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroAttempts()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroDelay()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero));
}
[Fact]
public void Build_WithAllOptions_Succeeds()
{
var client = new LmxProxyClientBuilder()
.WithHost("10.100.0.48")
.WithPort(50051)
.WithApiKey("test-key")
.WithTimeout(TimeSpan.FromSeconds(15))
.WithRetryPolicy(5, TimeSpan.FromSeconds(2))
.WithMetrics()
.WithCorrelationIdHeader("X-Correlation-ID")
.Build();
Assert.NotNull(client);
}
[Fact]
public void Build_WithTls_ValidatesCertificatePaths()
{
var builder = new LmxProxyClientBuilder()
.WithHost("localhost")
.WithTlsConfiguration(new ClientTlsConfiguration
{
UseTls = true,
ServerCaCertificatePath = "/nonexistent/cert.pem"
});
Assert.Throws<FileNotFoundException>(() => builder.Build());
}
[Fact]
public void WithHost_ThrowsOnNull()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(null!));
}
[Fact]
public void WithHost_ThrowsOnEmpty()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(""));
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.Configuration;
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class LmxProxyClientFactoryTests
{
[Fact]
public void CreateClient_BindsFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "10.100.0.48",
["LmxProxy:Port"] = "50052",
["LmxProxy:ApiKey"] = "test-key",
["LmxProxy:Retry:MaxAttempts"] = "5",
["LmxProxy:Retry:Delay"] = "00:00:02",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient();
Assert.NotNull(client);
}
[Fact]
public void CreateClient_NamedSection()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MyProxy:Host"] = "10.100.0.48",
["MyProxy:Port"] = "50052",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient("MyProxy");
Assert.NotNull(client);
}
[Fact]
public void CreateClient_BuilderAction()
{
var config = new ConfigurationBuilder().Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051));
Assert.NotNull(client);
}
}

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddLmxProxyClient_WithConfiguration_RegistersSingleton()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "localhost",
["LmxProxy:Port"] = "50051",
})
.Build();
var services = new ServiceCollection();
services.AddLmxProxyClient(config);
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
Assert.IsType<LmxProxyClient>(client);
}
[Fact]
public void AddLmxProxyClient_WithBuilderAction_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddLmxProxyClient(b => b.WithHost("localhost").WithPort(50051));
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddLmxProxyClient_WithNamedSection_RegistersSingleton()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["CustomProxy:Host"] = "10.0.0.1",
["CustomProxy:Port"] = "50052",
})
.Build();
var services = new ServiceCollection();
services.AddLmxProxyClient(config, "CustomProxy");
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddScopedLmxProxyClient_RegistersScoped()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "localhost",
})
.Build();
var services = new ServiceCollection();
services.AddScopedLmxProxyClient(config);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddNamedLmxProxyClient_RegistersKeyedSingleton()
{
var services = new ServiceCollection();
services.AddNamedLmxProxyClient("primary", b => b.WithHost("host-a").WithPort(50051));
services.AddNamedLmxProxyClient("secondary", b => b.WithHost("host-b").WithPort(50052));
using var provider = services.BuildServiceProvider();
var primary = provider.GetRequiredKeyedService<ILmxProxyClient>("primary");
var secondary = provider.GetRequiredKeyedService<ILmxProxyClient>("secondary");
Assert.NotNull(primary);
Assert.NotNull(secondary);
Assert.NotSame(primary, secondary);
}
}

View File

@@ -0,0 +1,157 @@
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class StreamingExtensionsTests
{
[Fact]
public async Task ReadStreamAsync_BatchesCorrectly()
{
var fake = new FakeLmxProxyClient();
var addresses = Enumerable.Range(0, 250).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 100))
{
results.Add(kvp);
}
// 250 tags at batchSize=100 => 3 batch calls (100, 100, 50)
Assert.Equal(3, fake.ReadBatchCalls.Count);
Assert.Equal(100, fake.ReadBatchCalls[0].Count);
Assert.Equal(100, fake.ReadBatchCalls[1].Count);
Assert.Equal(50, fake.ReadBatchCalls[2].Count);
Assert.Equal(250, results.Count);
}
[Fact]
public async Task ReadStreamAsync_RetriesOnError()
{
var fake = new FakeLmxProxyClient
{
ReadBatchExceptionToThrow = new InvalidOperationException("transient"),
ReadBatchExceptionCount = 1 // First call throws, second succeeds
};
var addresses = Enumerable.Range(0, 5).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 10))
{
results.Add(kvp);
}
// Should retry: first call throws, second succeeds
Assert.Equal(2, fake.ReadBatchCalls.Count);
Assert.Equal(5, results.Count);
}
[Fact]
public async Task WriteStreamAsync_BatchesAndReturnsCount()
{
var fake = new FakeLmxProxyClient();
var values = GenerateWriteValues(250);
int total = await fake.WriteStreamAsync(values, batchSize: 100);
Assert.Equal(250, total);
Assert.Equal(3, fake.WriteBatchCalls.Count);
Assert.Equal(100, fake.WriteBatchCalls[0].Count);
Assert.Equal(100, fake.WriteBatchCalls[1].Count);
Assert.Equal(50, fake.WriteBatchCalls[2].Count);
}
[Fact]
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
{
int maxConcurrency = 2;
int currentConcurrency = 0;
int maxObservedConcurrency = 0;
var lockObj = new object();
var source = GenerateAsyncSequence(10);
await source.ProcessInParallelAsync(async (item, ct) =>
{
int current;
lock (lockObj)
{
currentConcurrency++;
current = currentConcurrency;
if (current > maxObservedConcurrency)
maxObservedConcurrency = current;
}
await Task.Delay(50, ct);
lock (lockObj)
{
currentConcurrency--;
}
}, maxConcurrency: maxConcurrency);
Assert.True(maxObservedConcurrency <= maxConcurrency,
$"Max observed concurrency {maxObservedConcurrency} exceeded limit {maxConcurrency}");
}
[Fact]
public async Task SubscribeStreamAsync_YieldsFromChannel()
{
var fake = new FakeLmxProxyClient();
var addresses = new[] { "tag1", "tag2" };
using var cts = new CancellationTokenSource();
var results = new List<(string Tag, Vtq Vtq)>();
// Start the subscription stream in a background task
var streamTask = Task.Run(async () =>
{
await foreach (var item in fake.SubscribeStreamAsync(addresses, cts.Token))
{
results.Add(item);
if (results.Count >= 3)
await cts.CancelAsync();
}
});
// Wait for subscribe to be called with a polling loop
for (int i = 0; i < 50 && fake.CapturedOnUpdate is null; i++)
await Task.Delay(50);
// Simulate updates via captured callback
Assert.NotNull(fake.CapturedOnUpdate);
fake.CapturedOnUpdate!("tag1", new Vtq(1.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag2", new Vtq(2.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag1", new Vtq(3.0, DateTime.UtcNow, Quality.Good));
// Wait for stream task to complete (cancelled after 3 items)
try { await streamTask; }
catch (OperationCanceledException) { }
Assert.Equal(3, results.Count);
Assert.Equal("tag1", results[0].Tag);
Assert.Equal("tag2", results[1].Tag);
Assert.Equal("tag1", results[2].Tag);
}
private static async IAsyncEnumerable<KeyValuePair<string, TypedValue>> GenerateWriteValues(int count)
{
for (int i = 0; i < count; i++)
{
yield return new KeyValuePair<string, TypedValue>(
$"tag{i}",
new TypedValue { DoubleValue = i * 1.0 });
await Task.Yield();
}
}
private static async IAsyncEnumerable<int> GenerateAsyncSequence(int count)
{
for (int i = 0; i < count; i++)
{
yield return i;
await Task.Yield();
}
}
}

View File

@@ -18,6 +18,9 @@
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>