# Phase 6: Client Extras — Implementation Plan

**Date**: 2026-03-21
**Prerequisites**: Phase 5 complete and passing (Client Core — `ILmxProxyClient`, `LmxProxyClient` partial classes, `ClientMetrics`, `ISubscription`, `ApiKeyInfo` all functional with unit tests passing)
**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy`

## Guardrails

1. **Client targets .NET 10, AnyCPU** — latest C# features permitted.
2. **Polly v8 API** — `ResiliencePipeline`, `ResiliencePipelineBuilder`, `RetryStrategyOptions`. Do NOT use Polly v7 `IAsyncPolicy`, `Policy.Handle<>().WaitAndRetryAsync(...)`.
3. **Builder default port is 50051** (per design doc section 11 — resolved conflict).
4. **No new NuGet packages** — `Polly 8.5.2`, `Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Binder 10.0.0`, `Microsoft.Extensions.Logging.Abstractions 10.0.0` are already in the csproj.
5. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client`
6. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests`

## Step 1: LmxProxyClientBuilder

**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs`

Rewrite the builder for v2. Key changes from v1:
- Default port changes from `5050` to `50051`
- Retry uses Polly v8 `ResiliencePipeline` (built in `SetBuilderConfiguration`)
- `WithCorrelationIdHeader` support

### 1.1 Builder fields

```csharp
public class LmxProxyClientBuilder
{
    private string? _host;
    private int _port = 50051;  // CHANGED from 5050
    private string? _apiKey;
    private ILogger<LmxProxyClient>? _logger;
    private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
    private int _maxRetryAttempts = 3;
    private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
    private bool _enableMetrics;
    private string? _correlationIdHeader;
    private ClientTlsConfiguration? _tlsConfiguration;
```

### 1.2 Fluent methods

Each method returns `this` for chaining. Validation at call site:

| Method | Default | Validation |
|---|---|---|
| `WithHost(string host)` | Required | `!string.IsNullOrWhiteSpace(host)` |
| `WithPort(int port)` | 50051 | 1-65535 |
| `WithApiKey(string? apiKey)` | null | none |
| `WithLogger(ILogger<LmxProxyClient> logger)` | NullLogger | `!= null` |
| `WithTimeout(TimeSpan timeout)` | 30s | `> TimeSpan.Zero && <= TimeSpan.FromMinutes(10)` |
| `WithSslCredentials(string? certificatePath)` | disabled | creates/updates `_tlsConfiguration` with `UseTls=true` |
| `WithTlsConfiguration(ClientTlsConfiguration config)` | null | `!= null` |
| `WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)` | 3, 1s | `maxAttempts > 0`, `retryDelay > TimeSpan.Zero` |
| `WithMetrics()` | disabled | sets `_enableMetrics = true` |
| `WithCorrelationIdHeader(string headerName)` | null | `!string.IsNullOrEmpty` |

### 1.3 Build()

```csharp
public LmxProxyClient Build()
{
    if (string.IsNullOrWhiteSpace(_host))
        throw new InvalidOperationException("Host must be specified. Call WithHost() before Build().");

    ValidateTlsConfiguration();

    var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
    {
        DefaultTimeout = _defaultTimeout
    };

    client.SetBuilderConfiguration(new ClientConfiguration
    {
        MaxRetryAttempts = _maxRetryAttempts,
        RetryDelay = _retryDelay,
        EnableMetrics = _enableMetrics,
        CorrelationIdHeader = _correlationIdHeader
    });

    return client;
}
```

### 1.4 ValidateTlsConfiguration

If `_tlsConfiguration?.UseTls == true`:
- If `ServerCaCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`.
- If `ClientCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`.
- If `ClientKeyPath` is set and file doesn't exist → throw `FileNotFoundException`.

### 1.5 Polly v8 ResiliencePipeline setup (in LmxProxyClient.SetBuilderConfiguration)

This was defined in Step 4 of Phase 5. Verify it uses:

```csharp
using Polly;
using Polly.Retry;
using Grpc.Core;

_resiliencePipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = config.MaxRetryAttempts,
        Delay = config.RetryDelay,
        BackoffType = DelayBackoffType.Exponential,
        ShouldHandle = new PredicateBuilder()
            .Handle<RpcException>(ex =>
                ex.StatusCode == StatusCode.Unavailable ||
                ex.StatusCode == StatusCode.DeadlineExceeded ||
                ex.StatusCode == StatusCode.ResourceExhausted ||
                ex.StatusCode == StatusCode.Aborted),
        OnRetry = args =>
        {
            _logger.LogWarning(
                "Retry {Attempt}/{Max} after {Delay}ms — {Error}",
                args.AttemptNumber, config.MaxRetryAttempts,
                args.RetryDelay.TotalMilliseconds,
                args.Outcome.Exception?.Message ?? "unknown");
            return ValueTask.CompletedTask;
        }
    })
    .Build();
```

Backoff sequence: `retryDelay * 2^(attempt-1)` → 1s, 2s, 4s for defaults.

### 1.6 Verify build

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
```

## Step 2: ClientConfiguration

**File**: This is already defined in `LmxProxyClientBuilder.cs` (at the bottom of the file, as an `internal class`). Verify it contains:

```csharp
internal class ClientConfiguration
{
    public int MaxRetryAttempts { get; set; }
    public TimeSpan RetryDelay { get; set; }
    public bool EnableMetrics { get; set; }
    public string? CorrelationIdHeader { get; set; }
}
```

No changes needed if it matches.

## Step 3: ILmxProxyClientFactory + LmxProxyClientFactory

**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs`

### 3.1 Interface

```csharp
namespace ZB.MOM.WW.LmxProxy.Client;

public interface ILmxProxyClientFactory
{
    LmxProxyClient CreateClient();
    LmxProxyClient CreateClient(string configName);
    LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
```

### 3.2 Implementation

```csharp
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
    private readonly IConfiguration _configuration;

    public LmxProxyClientFactory(IConfiguration configuration)
    {
        _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
    }

    public LmxProxyClient CreateClient() => CreateClient("LmxProxy");

    public LmxProxyClient CreateClient(string configName)
    {
        IConfigurationSection section = _configuration.GetSection(configName);
        var options = new LmxProxyClientOptions();
        section.Bind(options);
        return BuildFromOptions(options);
    }

    public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
    {
        var builder = new LmxProxyClientBuilder();
        builderAction(builder);
        return builder.Build();
    }

    private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options)
    {
        var builder = new LmxProxyClientBuilder()
            .WithHost(options.Host)
            .WithPort(options.Port)
            .WithTimeout(options.Timeout)
            .WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay);

        if (!string.IsNullOrEmpty(options.ApiKey))
            builder.WithApiKey(options.ApiKey);

        if (options.EnableMetrics)
            builder.WithMetrics();

        if (!string.IsNullOrEmpty(options.CorrelationIdHeader))
            builder.WithCorrelationIdHeader(options.CorrelationIdHeader);

        if (options.UseSsl)
        {
            builder.WithTlsConfiguration(new ClientTlsConfiguration
            {
                UseTls = true,
                ServerCaCertificatePath = options.CertificatePath
            });
        }

        return builder.Build();
    }
}
```

### 3.3 Verify build

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
```

## Step 4: ServiceCollectionExtensions

**File**: `src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs`

### 4.1 Options classes

Define at the bottom of the file or in a separate `LmxProxyClientOptions.cs`:

```csharp
public class LmxProxyClientOptions
{
    public string Host { get; set; } = "localhost";
    public int Port { get; set; } = 50051;  // CHANGED from 5050
    public string? ApiKey { get; set; }
    public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
    public bool UseSsl { get; set; }
    public string? CertificatePath { get; set; }
    public bool EnableMetrics { get; set; }
    public string? CorrelationIdHeader { get; set; }
    public RetryOptions Retry { get; set; } = new();
}

public class RetryOptions
{
    public int MaxAttempts { get; set; } = 3;
    public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}
```

### 4.2 Extension methods

```csharp
public static class ServiceCollectionExtensions
{
    /// <summary>Registers a singleton ILmxProxyClient from the "LmxProxy" config section.</summary>
    public static IServiceCollection AddLmxProxyClient(
        this IServiceCollection services, IConfiguration configuration)
    {
        return services.AddLmxProxyClient(configuration, "LmxProxy");
    }

    /// <summary>Registers a singleton ILmxProxyClient from a named config section.</summary>
    public static IServiceCollection AddLmxProxyClient(
        this IServiceCollection services, IConfiguration configuration, string sectionName)
    {
        services.AddSingleton<ILmxProxyClientFactory>(
            sp => new LmxProxyClientFactory(configuration));
        services.AddSingleton<ILmxProxyClient>(sp =>
        {
            var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
            return factory.CreateClient(sectionName);
        });
        return services;
    }

    /// <summary>Registers a singleton ILmxProxyClient via builder action.</summary>
    public static IServiceCollection AddLmxProxyClient(
        this IServiceCollection services, Action<LmxProxyClientBuilder> configure)
    {
        services.AddSingleton<ILmxProxyClient>(sp =>
        {
            var builder = new LmxProxyClientBuilder();
            configure(builder);
            return builder.Build();
        });
        return services;
    }

    /// <summary>Registers a scoped ILmxProxyClient from the "LmxProxy" config section.</summary>
    public static IServiceCollection AddScopedLmxProxyClient(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddSingleton<ILmxProxyClientFactory>(
            sp => new LmxProxyClientFactory(configuration));
        services.AddScoped<ILmxProxyClient>(sp =>
        {
            var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
            return factory.CreateClient();
        });
        return services;
    }

    /// <summary>Registers a keyed singleton ILmxProxyClient.</summary>
    public static IServiceCollection AddNamedLmxProxyClient(
        this IServiceCollection services, string name, Action<LmxProxyClientBuilder> configure)
    {
        services.AddKeyedSingleton<ILmxProxyClient>(name, (sp, key) =>
        {
            var builder = new LmxProxyClientBuilder();
            configure(builder);
            return builder.Build();
        });
        return services;
    }
}
```

### 4.3 Verify build

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
```

## Step 5: StreamingExtensions

**File**: `src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs`

### 5.1 ReadStreamAsync

```csharp
public static class StreamingExtensions
{
    /// <summary>
    /// Reads multiple tags as an async stream in batches.
    /// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors.
    /// </summary>
    public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
        this ILmxProxyClient client,
        IEnumerable<string> addresses,
        int batchSize = 100,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(client);
        ArgumentNullException.ThrowIfNull(addresses);
        if (batchSize <= 0)
            throw new ArgumentOutOfRangeException(nameof(batchSize));

        var batch = new List<string>(batchSize);
        int consecutiveErrors = 0;
        const int maxConsecutiveErrors = 3;
        const int maxRetries = 2;

        foreach (string address in addresses)
        {
            cancellationToken.ThrowIfCancellationRequested();
            batch.Add(address);

            if (batch.Count >= batchSize)
            {
                await foreach (var kvp in ReadBatchWithRetry(
                    client, batch, maxRetries, cancellationToken))
                {
                    consecutiveErrors = 0;
                    yield return kvp;
                }
                // If we get here without yielding, it was an error
                // (handled inside ReadBatchWithRetry)
                batch.Clear();
            }
        }

        // Process remaining
        if (batch.Count > 0)
        {
            await foreach (var kvp in ReadBatchWithRetry(
                client, batch, maxRetries, cancellationToken))
            {
                yield return kvp;
            }
        }
    }

    private static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadBatchWithRetry(
        ILmxProxyClient client,
        List<string> batch,
        int maxRetries,
        [EnumeratorCancellation] CancellationToken ct)
    {
        int retries = 0;
        while (retries <= maxRetries)
        {
            IDictionary<string, Vtq>? results = null;
            try
            {
                results = await client.ReadBatchAsync(batch, ct);
            }
            catch when (retries < maxRetries)
            {
                retries++;
                continue;
            }

            if (results is not null)
            {
                foreach (var kvp in results)
                    yield return kvp;
                yield break;
            }
            retries++;
        }
    }
```

### 5.2 WriteStreamAsync

```csharp
    /// <summary>
    /// Writes values from an async enumerable in batches. Returns total count written.
    /// </summary>
    public static async Task<int> WriteStreamAsync(
        this ILmxProxyClient client,
        IAsyncEnumerable<KeyValuePair<string, TypedValue>> values,
        int batchSize = 100,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(client);
        ArgumentNullException.ThrowIfNull(values);
        if (batchSize <= 0)
            throw new ArgumentOutOfRangeException(nameof(batchSize));

        var batch = new Dictionary<string, TypedValue>(batchSize);
        int totalWritten = 0;

        await foreach (var kvp in values.WithCancellation(cancellationToken))
        {
            batch[kvp.Key] = kvp.Value;

            if (batch.Count >= batchSize)
            {
                await client.WriteBatchAsync(batch, cancellationToken);
                totalWritten += batch.Count;
                batch.Clear();
            }
        }

        if (batch.Count > 0)
        {
            await client.WriteBatchAsync(batch, cancellationToken);
            totalWritten += batch.Count;
        }

        return totalWritten;
    }
```

### 5.3 ProcessInParallelAsync

```csharp
    /// <summary>
    /// Processes items in parallel with a configurable max concurrency (default 4).
    /// </summary>
    public static async Task ProcessInParallelAsync<T>(
        this IAsyncEnumerable<T> source,
        Func<T, CancellationToken, Task> processor,
        int maxConcurrency = 4,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentNullException.ThrowIfNull(processor);
        if (maxConcurrency <= 0)
            throw new ArgumentOutOfRangeException(nameof(maxConcurrency));

        using var semaphore = new SemaphoreSlim(maxConcurrency);
        var tasks = new List<Task>();

        await foreach (T item in source.WithCancellation(cancellationToken))
        {
            await semaphore.WaitAsync(cancellationToken);

            tasks.Add(Task.Run(async () =>
            {
                try
                {
                    await processor(item, cancellationToken);
                }
                finally
                {
                    semaphore.Release();
                }
            }, cancellationToken));
        }

        await Task.WhenAll(tasks);
    }
```

### 5.4 SubscribeStreamAsync

```csharp
    /// <summary>
    /// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels.
    /// </summary>
    public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync(
        this ILmxProxyClient client,
        IEnumerable<string> addresses,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(client);
        ArgumentNullException.ThrowIfNull(addresses);

        var channel = Channel.CreateBounded<(string, Vtq)>(
            new BoundedChannelOptions(1000)
            {
                FullMode = BoundedChannelFullMode.DropOldest,
                SingleReader = true,
                SingleWriter = false
            });

        ISubscription? subscription = null;
        try
        {
            subscription = await client.SubscribeAsync(
                addresses,
                (tag, vtq) =>
                {
                    channel.Writer.TryWrite((tag, vtq));
                },
                ex =>
                {
                    channel.Writer.TryComplete(ex);
                },
                cancellationToken);

            await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
            {
                yield return item;
            }
        }
        finally
        {
            subscription?.Dispose();
            channel.Writer.TryComplete();
        }
    }
}
```

### 5.5 Verify build

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
```

## Step 6: Properties/AssemblyInfo.cs

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs`

Create this file if it doesn't already exist:

```csharp
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]
```

This allows the test project to access `internal` types like `ClientMetrics` and `ClientConfiguration`.

### 6.1 Verify build

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
```

## Step 7: Unit Tests

Add tests to the existing `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` project (created in Phase 5).

### 7.1 Builder Tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs`

```csharp
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();
        // Verify via reflection or by checking connection attempt URI
        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(""));
    }
}
```

### 7.2 Factory Tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs`

```csharp
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);
    }
}
```

### 7.3 StreamingExtensions Tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs`

```csharp
public class StreamingExtensionsTests
{
    [Fact]
    public async Task ReadStreamAsync_BatchesCorrectly()
    // Create mock client, provide 250 addresses with batchSize=100
    // Verify ReadBatchAsync called 3 times (100, 100, 50)

    [Fact]
    public async Task ReadStreamAsync_RetriesOnError()
    // Mock first ReadBatchAsync to throw, second to succeed
    // Verify results returned from second attempt

    [Fact]
    public async Task WriteStreamAsync_BatchesAndReturnsCount()
    // Provide async enumerable of 250 items, batchSize=100
    // Verify WriteBatchAsync called 3 times, total returned = 250

    [Fact]
    public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
    // Track concurrent count with SemaphoreSlim
    // maxConcurrency=2, verify never exceeds 2 concurrent calls

    [Fact]
    public async Task SubscribeStreamAsync_YieldsFromChannel()
    // Mock SubscribeAsync to invoke onUpdate callback with test values
    // Verify IAsyncEnumerable yields matching items
}
```

### 7.4 Run all tests

```bash
ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal"
```

## Step 8: Build Verification

Run full solution build and all tests:

```bash
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal"
```

## Completion Criteria

- [ ] `LmxProxyClientBuilder` with default port 50051, Polly v8 wiring, all fluent methods, TLS validation
- [ ] `ClientConfiguration` internal record with retry, metrics, correlation header fields
- [ ] `ILmxProxyClientFactory` + `LmxProxyClientFactory` with 3 `CreateClient` overloads
- [ ] `ServiceCollectionExtensions` with `AddLmxProxyClient` (3 overloads), `AddScopedLmxProxyClient`, `AddNamedLmxProxyClient`
- [ ] `LmxProxyClientOptions` + `RetryOptions` configuration classes
- [ ] `StreamingExtensions` with `ReadStreamAsync` (batched, 2 retries, 3 consecutive error abort), `WriteStreamAsync` (batched), `ProcessInParallelAsync` (SemaphoreSlim, max 4), `SubscribeStreamAsync` (Channel-based IAsyncEnumerable)
- [ ] `Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for test project
- [ ] Builder tests: validation, defaults, Polly pipeline wiring, TLS cert validation
- [ ] Factory tests: config binding from IConfiguration, named sections, builder action
- [ ] StreamingExtensions tests: batching, error recovery, parallel throttling, subscription streaming
- [ ] Solution builds cleanly
- [ ] All tests pass
