From 44177acf64f6f52aaaf3ece9901d6d30531d3a0c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Mar 2026 06:22:31 -0400 Subject: [PATCH] Add integration test harness: OpcUaServiceBuilder + OpcUaServerFixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpcUaServiceBuilder provides fluent API for constructing OpcUaService with dependency overrides (IMxProxy, IGalaxyRepository, IMxAccessClient). WithMxAccessClient skips the STA thread and COM interop entirely. OpcUaServerFixture wraps the service lifecycle with automatic port allocation (atomic counter starting at 16000), guaranteed cleanup via IAsyncLifetime, and factory methods for common test scenarios: - WithFakes() — FakeMxProxy + FakeGalaxyRepository with standard data - WithFakeMxAccessClient() — bypasses COM, fastest for most tests Also adds TestData helper with reusable hierarchy/attributes matching gr/layout.md, and 5 fixture tests verifying startup, shutdown, port isolation, and address space building. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs | 27 +++- .../OpcUaServiceBuilder.cs | 123 ++++++++++++++++++ .../Helpers/OpcUaServerFixture.cs | 103 +++++++++++++++ .../Helpers/OpcUaServerFixtureTests.cs | 86 ++++++++++++ .../Helpers/TestData.cs | 52 ++++++++ 5 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index b775e14..25fcd3a 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -22,11 +22,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host private readonly AppConfiguration _config; private readonly IMxProxy? _mxProxy; private readonly IGalaxyRepository? _galaxyRepository; + private readonly IMxAccessClient? _mxAccessClientOverride; + private readonly bool _hasMxAccessClientOverride; private CancellationTokenSource? _cts; private PerformanceMetrics? _metrics; private StaComThread? _staThread; private MxAccessClient? _mxAccessClient; + private IMxAccessClient? _mxAccessClientForWiring; private ChangeDetectionService? _changeDetection; private OpcUaServerHost? _serverHost; private LmxNodeManager? _nodeManager; @@ -59,11 +62,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host /// /// Test constructor. Accepts injected dependencies. /// - internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository) + internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository, + IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false) { _config = config; _mxProxy = mxProxy; _galaxyRepository = galaxyRepository; + _mxAccessClientOverride = mxAccessClientOverride; + _hasMxAccessClientOverride = hasMxAccessClientOverride; } public void Start() @@ -87,7 +93,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host _metrics = new PerformanceMetrics(); // Step 5: Create MxAccessClient → Connect - if (_mxProxy != null) + if (_hasMxAccessClientOverride) + { + // Test path: use injected IMxAccessClient directly (skips STA thread + COM) + _mxAccessClientForWiring = _mxAccessClientOverride; + if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected) + { + _mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); + } + } + else if (_mxProxy != null) { try { @@ -121,8 +136,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } // Step 8: Create OPC UA server host + node manager - IMxAccessClient mxClient = _mxAccessClient ?? (IMxAccessClient)new NullMxAccessClient(); - _serverHost = new OpcUaServerHost(_config.OpcUa, mxClient, _metrics); + var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring ?? new NullMxAccessClient(); + _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics); // Step 9-10: Query hierarchy, start server, build address space if (_galaxyRepository != null && _galaxyStats.DbConnected) @@ -170,7 +185,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host // Step 13: Dashboard _healthCheck = new HealthCheckService(); _statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds); - _statusReport.SetComponents(_mxAccessClient, _metrics, _galaxyStats, _serverHost); + _statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost); if (_config.Dashboard.Enabled) { @@ -253,7 +268,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } // Accessors for testing - internal IMxAccessClient? MxClient => _mxAccessClient; + internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; internal PerformanceMetrics? Metrics => _metrics; internal OpcUaServerHost? ServerHost => _serverHost; internal LmxNodeManager? NodeManagerInstance => _nodeManager; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs new file mode 100644 index 0000000..f728386 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Host +{ + /// + /// Fluent builder for constructing OpcUaService with dependency overrides. + /// Used by integration tests to substitute fakes for COM/DB components. + /// + internal class OpcUaServiceBuilder + { + private AppConfiguration _config = new AppConfiguration(); + private IMxProxy? _mxProxy; + private IGalaxyRepository? _galaxyRepository; + private IMxAccessClient? _mxAccessClient; + private bool _mxProxySet; + private bool _galaxyRepositorySet; + private bool _mxAccessClientSet; + + public OpcUaServiceBuilder WithConfig(AppConfiguration config) + { + _config = config; + return this; + } + + public OpcUaServiceBuilder WithOpcUaPort(int port) + { + _config.OpcUa.Port = port; + return this; + } + + public OpcUaServiceBuilder WithGalaxyName(string name) + { + _config.OpcUa.GalaxyName = name; + return this; + } + + public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy) + { + _mxProxy = proxy; + _mxProxySet = true; + return this; + } + + public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository) + { + _galaxyRepository = repository; + _galaxyRepositorySet = true; + return this; + } + + /// + /// Override the MxAccessClient directly, skipping STA thread and COM interop entirely. + /// When set, the service will use this client instead of creating one from IMxProxy. + /// + public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client) + { + _mxAccessClient = client; + _mxAccessClientSet = true; + return this; + } + + public OpcUaServiceBuilder WithHierarchy(List hierarchy, List attributes) + { + if (!_galaxyRepositorySet) + { + var fake = new FakeBuilderGalaxyRepository(); + _galaxyRepository = fake; + _galaxyRepositorySet = true; + } + + if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo) + { + fakeRepo.Hierarchy = hierarchy; + fakeRepo.Attributes = attributes; + } + + return this; + } + + public OpcUaServiceBuilder DisableDashboard() + { + _config.Dashboard.Enabled = false; + return this; + } + + public OpcUaServiceBuilder DisableChangeDetection() + { + _config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue; + return this; + } + + public OpcUaService Build() + { + return new OpcUaService( + _config, + _mxProxySet ? _mxProxy : null, + _galaxyRepositorySet ? _galaxyRepository : null, + _mxAccessClientSet ? _mxAccessClient : null, + _mxAccessClientSet); + } + + /// + /// Internal fake repository used by WithHierarchy for convenience. + /// + private class FakeBuilderGalaxyRepository : IGalaxyRepository + { + public event System.Action? OnGalaxyChanged; + public List Hierarchy { get; set; } = new(); + public List Attributes { get; set; } = new(); + + public System.Threading.Tasks.Task> GetHierarchyAsync(System.Threading.CancellationToken ct = default) + => System.Threading.Tasks.Task.FromResult(Hierarchy); + public System.Threading.Tasks.Task> GetAttributesAsync(System.Threading.CancellationToken ct = default) + => System.Threading.Tasks.Task.FromResult(Attributes); + public System.Threading.Tasks.Task GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default) + => System.Threading.Tasks.Task.FromResult(System.DateTime.UtcNow); + public System.Threading.Tasks.Task TestConnectionAsync(System.Threading.CancellationToken ct = default) + => System.Threading.Tasks.Task.FromResult(true); + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs new file mode 100644 index 0000000..c66dee5 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs @@ -0,0 +1,103 @@ +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers +{ + /// + /// xUnit fixture that manages an OpcUaService lifecycle with automatic port allocation. + /// Guarantees no port conflicts between parallel tests. + /// + /// Usage (per-test): + /// var fixture = OpcUaServerFixture.WithFakes(); + /// await fixture.InitializeAsync(); + /// try { ... } finally { await fixture.DisposeAsync(); } + /// + /// Usage (skip COM entirely): + /// var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); + /// + internal class OpcUaServerFixture : IAsyncLifetime + { + private static int _nextPort = 16000; + + public OpcUaService Service { get; private set; } = null!; + public int OpcUaPort { get; } + public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa"; + + private readonly OpcUaServiceBuilder _builder; + private bool _started; + + public OpcUaServerFixture(OpcUaServiceBuilder builder) + { + OpcUaPort = Interlocked.Increment(ref _nextPort); + _builder = builder; + _builder.WithOpcUaPort(OpcUaPort); + _builder.DisableDashboard(); + } + + /// + /// Creates fixture with FakeMxProxy + FakeGalaxyRepository (standard test data). + /// The STA thread and COM interop run against FakeMxProxy. + /// + public static OpcUaServerFixture WithFakes( + FakeMxProxy? proxy = null, + FakeGalaxyRepository? repo = null) + { + var p = proxy ?? new FakeMxProxy(); + var r = repo ?? new FakeGalaxyRepository + { + Hierarchy = TestData.CreateStandardHierarchy(), + Attributes = TestData.CreateStandardAttributes() + }; + + var builder = new OpcUaServiceBuilder() + .WithMxProxy(p) + .WithGalaxyRepository(r) + .WithGalaxyName("TestGalaxy"); + + return new OpcUaServerFixture(builder); + } + + /// + /// Creates fixture using FakeMxAccessClient directly — skips STA thread + COM entirely. + /// Fastest option for tests that don't need real COM interop. + /// + public static OpcUaServerFixture WithFakeMxAccessClient( + FakeMxAccessClient? mxClient = null, + FakeGalaxyRepository? repo = null) + { + var client = mxClient ?? new FakeMxAccessClient(); + var r = repo ?? new FakeGalaxyRepository + { + Hierarchy = TestData.CreateStandardHierarchy(), + Attributes = TestData.CreateStandardAttributes() + }; + + var builder = new OpcUaServiceBuilder() + .WithMxAccessClient(client) + .WithGalaxyRepository(r) + .WithGalaxyName("TestGalaxy"); + + return new OpcUaServerFixture(builder); + } + + public Task InitializeAsync() + { + Service = _builder.Build(); + Service.Start(); + _started = true; + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + if (_started) + { + try { Service.Stop(); } + catch { /* swallow cleanup errors */ } + } + return Task.CompletedTask; + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs new file mode 100644 index 0000000..7ca4187 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers +{ + public class OpcUaServerFixtureTests + { + [Fact] + public async Task WithFakes_StartsAndStops() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + + fixture.Service.ShouldNotBeNull(); + fixture.Service.MxClient.ShouldNotBeNull(); + fixture.Service.MxClient!.State.ShouldBe(ConnectionState.Connected); + fixture.Service.GalaxyStatsInstance.ShouldNotBeNull(); + fixture.Service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy"); + fixture.OpcUaPort.ShouldBeGreaterThan(16000); + fixture.EndpointUrl.ShouldContain(fixture.OpcUaPort.ToString()); + + await fixture.DisposeAsync(); + } + + [Fact] + public async Task WithFakeMxAccessClient_SkipsCom() + { + var mxClient = new FakeMxAccessClient(); + var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); + await fixture.InitializeAsync(); + + fixture.Service.MxClient.ShouldBe(mxClient); + mxClient.State.ShouldBe(ConnectionState.Connected); + + await fixture.DisposeAsync(); + } + + [Fact] + public async Task MultipleFixtures_GetUniquePortsAutomatically() + { + var fixture1 = OpcUaServerFixture.WithFakeMxAccessClient(); + var fixture2 = OpcUaServerFixture.WithFakeMxAccessClient(); + + fixture1.OpcUaPort.ShouldNotBe(fixture2.OpcUaPort); + + // Both can start without port conflicts + await fixture1.InitializeAsync(); + await fixture2.InitializeAsync(); + + fixture1.Service.ShouldNotBeNull(); + fixture2.Service.ShouldNotBeNull(); + + await fixture1.DisposeAsync(); + await fixture2.DisposeAsync(); + } + + [Fact] + public async Task Shutdown_CompletesWithin30Seconds() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + + var sw = Stopwatch.StartNew(); + await fixture.DisposeAsync(); + sw.Stop(); + + sw.Elapsed.TotalSeconds.ShouldBeLessThan(30); + } + + [Fact] + public async Task WithFakes_BuildsAddressSpace() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + + fixture.Service.GalaxyStatsInstance!.ObjectCount.ShouldBe(5); + fixture.Service.GalaxyStatsInstance.AttributeCount.ShouldBe(6); + fixture.Service.GalaxyStatsInstance.DbConnected.ShouldBe(true); + + await fixture.DisposeAsync(); + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs new file mode 100644 index 0000000..5a78985 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers +{ + /// + /// Reusable test data matching the Galaxy hierarchy from gr/layout.md. + /// + public static class TestData + { + public static List CreateStandardHierarchy() + { + return new List + { + new GalaxyObjectInfo { GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true }, + new GalaxyObjectInfo { GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea", ParentGobjectId = 1, IsArea = true }, + new GalaxyObjectInfo { GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false }, + new GalaxyObjectInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false }, + new GalaxyObjectInfo { GobjectId = 5, TagName = "MESReceiver_001", ContainedName = "MESReceiver", BrowseName = "MESReceiver", ParentGobjectId = 3, IsArea = false }, + }; + } + + public static List CreateStandardAttributes() + { + return new List + { + new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false }, + new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineCode", FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false }, + new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false }, + new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false }, + new GalaxyAttributeInfo { GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInBatchID", FullTagReference = "MESReceiver_001.MoveInBatchID", MxDataType = 5, IsArray = false }, + new GalaxyAttributeInfo { GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInPartNumbers", FullTagReference = "MESReceiver_001.MoveInPartNumbers[]", MxDataType = 5, IsArray = true, ArrayDimension = 50 }, + }; + } + + public static List CreateMinimalHierarchy() + { + return new List + { + new GalaxyObjectInfo { GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false } + }; + } + + public static List CreateMinimalAttributes() + { + return new List + { + new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false } + }; + } + } +}