using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// End-to-end smoke against a live opc-plc (task #215). Drives the real /// OPC UA Secure Channel + Session + MonitoredItem exchange — no mocks. Every /// test here proves a capability surface that loopback against our own server /// couldn't exercise cleanly: real cert negotiation, real endpoint descriptions, /// real simulated nodes that change without a write. /// [Collection(OpcPlcCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "opc-plc")] public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) { /// Verifies that the client can connect and read a node through the real OPC UA stack. [Fact] public async Task Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-read"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var snapshots = await drv.ReadAsync( [OpcPlcProfile.StepUpRef], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(1); snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end"); snapshots[0].Value.ShouldNotBeNull("StepUp always has a current value"); } /// Verifies that the client can read a batch of varied types from the simulator. [Fact] public async Task Client_reads_batch_of_varied_types_from_live_simulator() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-batch"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var snapshots = await drv.ReadAsync( [OpcPlcProfile.StepUpRef, OpcPlcProfile.RandomSignedInt32Ref, OpcPlcProfile.AlternatingBooleanRef], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(3); foreach (var s in snapshots) { s.StatusCode.ShouldBe(0u); s.Value.ShouldNotBeNull(); } // AlternatingBoolean should decode as a bool specifically — catches a common // attribute-mapping regression where the driver stringifies variant values. snapshots[2].Value.ShouldBeOfType(); } /// Verifies that the client can subscribe to data changes from the live server. [Fact] public async Task Client_subscribe_receives_StepUp_data_changes_from_live_server() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-sub"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var observed = new List(); var gate = new SemaphoreSlim(0); drv.OnDataChange += (_, e) => { lock (observed) observed.Add(e); gate.Release(); }; var handle = await drv.SubscribeAsync( [OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); // FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver. // Wait up to 3 s to tolerate container warm-up + first-publish delay. var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); got.ShouldBeTrue("opc-plc FastUInt1 must publish at least one data change within 3s"); int observedCount; lock (observed) observedCount = observed.Count; observedCount.ShouldBeGreaterThan(0); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } /// /// Review 2026-10-21 O-9 + lifecycle generation, through the real SDK: a shutdown with several live /// subscriptions completes inside the one teardown budget, lands Unknown, and a re-initialise of the same /// instance reconnects and reads. /// [Fact] public async Task Shutdown_with_live_subscriptions_is_bounded_and_the_instance_reinitialises() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var ct = TestContext.Current.CancellationToken; var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-lifecycle"); await drv.InitializeAsync("{}", ct); for (var i = 0; i < 3; i++) await drv.SubscribeAsync([OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), ct); var sw = System.Diagnostics.Stopwatch.StartNew(); await drv.ShutdownAsync(ct); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "one 4 s budget bounds the whole teardown"); drv.GetHealth().State.ShouldBe(DriverState.Unknown); await drv.InitializeAsync("{}", ct); var read = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct); read.Single().StatusCode.ShouldBe(0u, "the re-initialised instance must be fully usable"); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// The health contract against a real server: a write the server REFUSES (a string into the UInt32 StepUp — /// BadTypeMismatch) is a reply — the refusal status passes through and the driver stays Healthy with its stamp /// advanced. /// [Fact] public async Task A_server_refused_write_is_a_reply_and_keeps_the_driver_healthy() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var ct = TestContext.Current.CancellationToken; var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-refused-write"); await drv.InitializeAsync("{}", ct); var before = drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield(); var result = await drv.WriteAsync([new WriteRequest(OpcPlcProfile.StepUpRef, "not-a-number")], ct); (result.Single().StatusCode & 0x80000000u).ShouldNotBe(0u, "opc-plc refuses a string written into a UInt32"); result.Single().StatusCode.ShouldNotBe(0x80050000u, "a server refusal is not a local comms error"); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); } }