# Phase 7: Integration Tests & Deployment — Implementation Plan **Date**: 2026-03-21 **Prerequisites**: Phase 4 (Host complete) and Phase 6 (Client complete) both passing. All unit tests green. **Working Directory (Mac)**: `/Users/dohertj2/Desktop/scadalink-design/lmxproxy` **Working Directory (windev)**: `C:\src\lmxproxy` **windev SSH**: `ssh windev` (alias configured in `~/.ssh/config`, passwordless ed25519, user `dohertj2`) ## Guardrails 1. **Never stop the v1 service until v2 is verified** — deploy v2 on alternate ports first. 2. **Take a Veeam backup before cutover** — provides rollback point. 3. **Integration tests run from Mac against windev** — they use `Grpc.Net.Client` which is cross-platform. 4. **All integration tests must pass before cutover**. 5. **API keys**: The existing `apikeys.json` on windev is the source of truth for valid keys. Read it to get test keys. 6. **Real MxAccess tags**: Use the `TestChildObject` tags on windev's AVEVA System Platform instance. Available tags cover all TypedValue cases: - `TestChildObject.TestBool` (bool) - `TestChildObject.TestInt` (int) - `TestChildObject.TestFloat` (float) - `TestChildObject.TestDouble` (double) - `TestChildObject.TestString` (string) - `TestChildObject.TestDateTime` (datetime) - `TestChildObject.TestBoolArray[]` (bool array) - `TestChildObject.TestDateTimeArray[]` (datetime array) - `TestChildObject.TestDoubleArray[]` (double array) - `TestChildObject.TestFloatArray[]` (float array) - `TestChildObject.TestIntArray[]` (int array) - `TestChildObject.TestStringArray[]` (string array) ## Step 1: Build Host on windev ### 1.1 Pull latest code ```bash ssh windev "cd C:\src\lmxproxy && git pull" ``` If the repo doesn't exist on windev yet: ```bash ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy" ``` ### 1.2 Publish Host binary ```bash ssh windev "cd C:\src\lmxproxy && dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2\" ``` **Expected output**: `C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe` plus dependencies. ### 1.3 Create v2 appsettings.json Create `C:\publish-v2\appsettings.json` configured for testing on alternate ports: ```bash ssh windev "powershell -Command \"@' { \"GrpcPort\": 50052, \"ApiKeyConfigFile\": \"apikeys.json\", \"Connection\": { \"MonitorIntervalSeconds\": 5, \"ConnectionTimeoutSeconds\": 30, \"ReadTimeoutSeconds\": 5, \"WriteTimeoutSeconds\": 5, \"MaxConcurrentOperations\": 10, \"AutoReconnect\": true }, \"Subscription\": { \"ChannelCapacity\": 1000, \"ChannelFullMode\": \"DropOldest\" }, \"HealthCheck\": { \"Enabled\": true, \"TestTagAddress\": \"TestChildObject.TestBool\", \"MaxStaleDataMinutes\": 5 }, \"Tls\": { \"Enabled\": false }, \"WebServer\": { \"Enabled\": true, \"Port\": 8081 }, \"Serilog\": { \"MinimumLevel\": { \"Default\": \"Information\", \"Override\": { \"Microsoft\": \"Warning\", \"System\": \"Warning\", \"Grpc\": \"Information\" } }, \"WriteTo\": [ { \"Name\": \"Console\" }, { \"Name\": \"File\", \"Args\": { \"path\": \"logs/lmxproxy-v2-.txt\", \"rollingInterval\": \"Day\", \"retainedFileCountLimit\": 30 } } ] } } '@ | Set-Content -Path 'C:\publish-v2\appsettings.json' -Encoding UTF8\"" ``` **Key differences from production config**: gRPC port is 50052 (not 50051), web port is 8081 (not 8080), log file prefix is `lmxproxy-v2-`. ### 1.4 Copy apikeys.json If v2 should use the same API keys as v1: ```bash ssh windev "copy C:\publish\apikeys.json C:\publish-v2\apikeys.json" ``` If `C:\publish\apikeys.json` doesn't exist (the v2 service will auto-generate one on first start): ```bash ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate" ``` ### 1.5 Verify the publish directory ```bash ssh windev "dir C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe && dir C:\publish-v2\appsettings.json" ``` ## Step 2: Deploy v2 Host Service ### 2.1 Install as a separate Topshelf service The v2 service runs alongside v1 on different ports. Install with a distinct service name: ```bash ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\" -displayname \"SCADA Bridge LMX Proxy V2\" -description \"LmxProxy v2 gRPC service (test deployment)\" --autostart" ``` ### 2.2 Start the v2 service ```bash ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2" ``` ### 2.3 Wait 10 seconds for startup, then verify ```bash ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host.V2" ``` Expected: `STATE: 4 RUNNING`. ### 2.4 Verify status page From Mac, use curl to check the v2 status page: ```bash curl -s http://10.100.0.48:8081/ | head -20 ``` Expected: HTML containing "LmxProxy Status Dashboard". ```bash curl -s http://10.100.0.48:8081/api/health ``` Expected: `OK` with HTTP 200. ```bash curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool | head -30 ``` Expected: JSON with `serviceName`, `connection.isConnected: true`, version info. ### 2.5 Verify MxAccess connected The status page should show `MxAccess Connection: Connected`. If it shows `Disconnected`, check the logs: ```bash ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\"" ``` ### 2.6 Read the apikeys.json to get test keys ```bash ssh windev "type C:\publish-v2\apikeys.json" ``` Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure: ```json { "Keys": [ { "Key": "abc123...", "Role": "ReadWrite", "Description": "Default ReadWrite key" }, { "Key": "def456...", "Role": "ReadOnly", "Description": "Default ReadOnly key" } ] } ``` ## Step 3: Create Integration Test Project ### 3.1 Create project On windev (or Mac — the test project is .NET 10 and cross-platform): ```bash cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.IntegrationTests -o tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --framework net10.0 ``` ### 3.2 Configure csproj **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj` ```xml net10.0 latest enable false PreserveNewest ``` ### 3.3 Add to solution Edit `ZB.MOM.WW.LmxProxy.slnx`: ```xml ``` ### 3.4 Create test configuration **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json` ```json { "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" } } ``` **IMPORTANT**: After reading the actual `apikeys.json` from windev in Step 2.6, replace the placeholder values with the real keys. ### 3.5 Create test base class **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs` ```csharp 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(); } } } ``` ## Step 4: Integration Test Scenarios ### 4.1 Connection Lifecycle **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs` ```csharp 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); // Expect RpcException with StatusCode.Unauthenticated 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()); } } ``` ### 4.2 Read Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs` ```csharp 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_MultiplesTags_ReturnsDictionary() { var tags = new[] { "TestChildObject.TestString", "TestChildObject.TestString" }; var results = await Client!.ReadBatchAsync(tags); Assert.Equal(2, results.Count); Assert.True(results.ContainsKey("TestChildObject.TestString")); Assert.True(results.ContainsKey("TestChildObject.TestString")); } [Fact] public async Task Read_NonexistentTag_ReturnsBadQuality() { // Reading a tag that doesn't exist should return Bad quality // (or throw — depends on Host implementation. Adjust assertion accordingly.) var vtq = await Client!.ReadAsync("NonExistent.Tag.12345"); // If the Host returns success=false, ReadAsync will throw. // If it returns success=true with bad quality, check quality. // Adjust based on actual behavior. } } ``` ### 4.3 Write Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs` ```csharp 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}"; // Write to a writable string tag await Client!.WriteAsync("TestChildObject.TestString", new TypedValue { StringValue = testValue }); // Read back and verify 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); } } ``` ### 4.4 Subscribe Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs` ```csharp 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}"); // Verify the VTQ has correct structure var first = received[0]; Assert.Equal("TestChildObject.TestInt", first.Tag); Assert.NotNull(first.Vtq.Value); // ScanTime should be a DateTime value Assert.True(first.Vtq.Timestamp > DateTime.MinValue); } } ``` ### 4.5 WriteBatchAndWait Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs` ```csharp using ZB.MOM.WW.LmxProxy.Client.Domain; namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; public class WriteBatchAndWaitTests : IntegrationTestBase { [Fact] public async Task WriteBatchAndWait_TypeAwareComparison() { // This test requires a writable tag and a flag tag. // Adjust tag names based on available tags in TestChildObject. // Example: write values and poll a flag. var values = new Dictionary { ["TestChildObject.TestString"] = new TypedValue { StringValue = "BatchTest" } }; // Poll the same tag we wrote to (simple self-check) 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); } } ``` ### 4.6 CheckApiKey Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs` ```csharp 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); } } ``` ## Step 5: Run Integration Tests ### 5.1 Build the test project (from Mac) ```bash cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy dotnet build tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests ``` ### 5.2 Run integration tests against v2 on alternate port ```bash dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal ``` All tests should pass against `10.100.0.48:50052`. ### 5.3 Debug failures If tests fail, check: 1. v2 service is running: `ssh windev "sc query ZB.MOM.WW.LmxProxy.Host.V2"` 2. v2 service logs: `ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i error"` 3. Network connectivity: `curl -s http://10.100.0.48:8081/api/health` 4. API keys match: `ssh windev "type C:\publish-v2\apikeys.json"` ### 5.4 Verify metrics after test run ```bash curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool ``` Should show non-zero operation counts for Read, ReadBatch, Write, etc. ## Step 6: Cutover **Only proceed if ALL integration tests pass.** ### 6.1 Stop v1 service ```bash ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host" ``` Verify stopped: ```bash ssh windev "sc query ZB.MOM.WW.LmxProxy.Host" ``` Expected: `STATE: 1 STOPPED`. ### 6.2 Stop v2 service ```bash ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host.V2" ``` ### 6.3 Reconfigure v2 to production ports Update `C:\publish-v2\appsettings.json`: - Change `GrpcPort` from `50052` to `50051` - Change `WebServer.Port` from `8081` to `8080` - Change log file prefix from `lmxproxy-v2-` to `lmxproxy-` ```bash ssh windev "powershell -Command \"(Get-Content 'C:\publish-v2\appsettings.json') -replace '50052','50051' -replace '8081','8080' -replace 'lmxproxy-v2-','lmxproxy-' | Set-Content 'C:\publish-v2\appsettings.json'\"" ``` ### 6.4 Uninstall v1 service ```bash ssh windev "C:\publish\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host\"" ``` ### 6.5 Uninstall v2 test service and reinstall as production service ```bash ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\"" ``` ```bash ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host\" -displayname \"SCADA Bridge LMX Proxy\" -description \"LmxProxy v2 gRPC service\" --autostart" ``` ### 6.6 Start the production service ```bash ssh windev "sc start ZB.MOM.WW.LmxProxy.Host" ``` ### 6.7 Verify on production ports ```bash ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host" ``` Expected: `STATE: 4 RUNNING`. ```bash curl -s http://10.100.0.48:8080/api/health ``` Expected: `OK`. ```bash curl -s http://10.100.0.48:8080/api/status | python3 -m json.tool | head -15 ``` Expected: Connected, version shows v2. ### 6.8 Update test configuration and re-run integration tests Update `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json`: - Change `Port` from `50052` to `50051` ```bash dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal ``` All tests should pass on the production port. ### 6.9 Configure service recovery ```bash ssh windev "sc failure ZB.MOM.WW.LmxProxy.Host reset= 86400 actions= restart/60000/restart/300000/restart/600000" ``` This configures: restart after 1 min on first failure, 5 min on second, 10 min on subsequent. Reset counter after 1 day (86400 seconds). ## Step 7: Documentation Updates ### 7.1 Update windev.md Add a section about the LmxProxy v2 service to `/Users/dohertj2/Desktop/scadalink-design/windev.md`: ```markdown ## LmxProxy v2 | Field | Value | |---|---| | Service Name | ZB.MOM.WW.LmxProxy.Host | | Display Name | SCADA Bridge LMX Proxy | | gRPC Port | 50051 | | Status Page | http://10.100.0.48:8080/ | | Health Endpoint | http://10.100.0.48:8080/api/health | | Publish Directory | C:\publish-v2\ | | API Keys | C:\publish-v2\apikeys.json | | Logs | C:\publish-v2\logs\ | | Protocol | v2 (TypedValue + QualityCode) | ``` ### 7.2 Update lmxproxy CLAUDE.md If `lmxproxy/CLAUDE.md` references v1 behavior, update: - Change "currently v1 protocol" references to "v2 protocol" - Update publish directory references from `C:\publish\` to `C:\publish-v2\` - Update any value conversion notes (no more string heuristics) ### 7.3 Clean up v1 publish directory (optional) ```bash ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup" ``` ## Step 8: Veeam Backup ### 8.1 Take incremental backup ```bash ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\"" ``` ### 8.2 Wait for backup to complete (check status) ```bash ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\"" ``` Expected: `State: Stopped, Result: Success`. ### 8.3 Get the restore point ID ```bash ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object Id, CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\"" ``` ### 8.4 Record in windev.md Add a new row to the Restore Points table in `windev.md`: ```markdown | `XXXXXXXX` | 2026-XX-XX XX:XX | Increment | **Post-v2 deployment** — LmxProxy v2 live on port 50051 | ``` Replace placeholders with actual restore point ID and timestamp. ## Completion Criteria - [ ] v2 Host binary published to `C:\publish-v2\` on windev - [ ] v2 service installed and running on alternate ports (50052/8081) — verified via status page - [ ] Integration test project created at `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/` - [ ] All integration tests pass against v2 on alternate ports: - [ ] Connect/disconnect lifecycle - [ ] Read string tag `TestChildObject.TestString` — value "JoeDev", Good quality - [ ] Read writable tag `TestChildObject.TestString` - [ ] Write string then read-back verification - [ ] ReadBatch multiple tags - [ ] Subscribe to `TestChildObject.TestInt` — verify updates received with TypedValue + QualityCode - [ ] WriteBatchAndWait with type-aware flag comparison - [ ] CheckApiKey — valid ReadWrite, valid ReadOnly, invalid - [ ] Write with ReadOnly key — PermissionDenied - [ ] Connect with invalid API key — Unauthenticated - [ ] v1 service stopped and uninstalled - [ ] v2 service reconfigured to production ports (50051/8080) and reinstalled - [ ] All integration tests pass on production ports - [ ] Service recovery configured (restart on failure) - [ ] `windev.md` updated with v2 service details - [ ] `lmxproxy/CLAUDE.md` updated for v2 - [ ] Veeam backup taken and restore point ID recorded in `windev.md` - [ ] v1 publish directory backed up or removed