From 1aa13ebd27ad5c530e003e0e88a4957ba628d410 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 08:46:28 -0400 Subject: [PATCH] test(otopcua): end-to-end discovered-node injection + value flow --- .../DiscoveryInjectionEndToEndTests.cs | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs new file mode 100644 index 00000000..1a60b9a4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs @@ -0,0 +1,348 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the +/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites +/// (which wire the OPC UA publish side as a and assert on the +/// intercepted / +/// messages), this suite wires the FULL real chain: +/// +/// +/// a real (resolves equipment, maps via +/// , extends the live-value routing map, caches the plan); +/// a real as its opcUaPublishActor seam (so +/// MaterialiseDiscoveredNodes + AttributeValueUpdate are actually handled, not +/// intercepted); +/// a real over a recording +/// (so the materialise + value-write reach the sink). +/// +/// +/// +/// The assertions are therefore made on the SINK's recorded EnsureVariable / +/// RaiseNodesAddedModelChange / WriteValue calls — i.e. the discovered node was +/// materialised through the real applier AND a published value surfaces +/// at the mapped NodeId (in production this overwrites the BadWaitingForInitialData seed that +/// OtOpcUaNodeManager.EnsureVariable stamps on a freshly-materialised variable; the recording +/// sink does not model that seed, so the faithful assertion available here is that the live value +/// lands Good at the same NodeId the materialise created). +/// +/// +/// +/// Seam choices (faithful to the sibling suites). Discovery is driven by Telling the host +/// directly, and the polled value by Telling +/// directly — exactly the seams the Task-7/8 +/// tests use (there is no test seam to drive a real poll loop through a +/// child to Connected, and the spawned child is a ). The publish +/// actor is wired WITHOUT a dbFactory, so the host's apply-time +/// falls back to a raw sink.RebuildAddressSpace() (no EnsureVariable); this keeps the +/// ONLY EnsureVariable traffic on the sink the discovered-node materialise itself, so the +/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing +/// map) is fully real. +/// +/// +[Trait("Category", "Unit")] +public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node"); + private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); + private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); + + // The FixedTree node the driver "discovers": FOCAS//Identity/SeriesNumber, a String value, + // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored + // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber. + private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber"; + private const string FixedTreeDisplayName = "SeriesNumber"; + + private static DiscoveredNode[] FixedTreeNodes() => new[] + { + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, + BrowseName: "SeriesNumber", + DisplayName: FixedTreeDisplayName, + FullReference: FixedTreeRef, + DataType: DriverDataType.String, + IsArray: false, + ArrayDim: null, + Writable: false, + IsHistorized: false), + }; + + /// + /// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows + /// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a + /// real publish actor + real applier + recording sink, then asserts: + /// (a) the sink recorded an EnsureVariable for the FixedTree node under EQ-1 (materialised + /// through the REAL applier — the node now exists in the served address space), with a + /// RaiseNodesAddedModelChange under EQ-1 so connected clients refresh; + /// (b) after an for the FixedTree ref, the + /// sink recorded a WriteValue at THAT SAME NodeId carrying the value with + /// — proving the live value routed end-to-end and (in production) + /// overwrote the BadWaitingForInitialData seed. + /// + [Fact] + public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, + (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); + + var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId); + + // Driver reports its captured FixedTree (the faithful Task-7/8 seam). + host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); + + // (a) The discovered variable was materialised through the REAL applier onto the sink, under EQ-1. + string fixedTreeNodeId = null!; + AwaitAssert(() => + { + var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName); + v.NodeId.ShouldNotBeNull(); // EnsureVariable recorded for the FixedTree node + v.NodeId.ShouldStartWith("EQ-1"); // grafted UNDER the bound equipment root + v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink + v.Writable.ShouldBeFalse(); // discovered nodes are read-only + fixedTreeNodeId = v.NodeId; + sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment + }, duration: Timeout); + + // (b) A value published for the FixedTree ref routes to the mapped NodeId and lands Good — the live + // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue). + host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts)); + + AwaitAssert(() => + { + var write = sink.Values.SingleOrDefault(x => x.NodeId == fixedTreeNodeId); + write.NodeId.ShouldBe(fixedTreeNodeId); + write.Value.ShouldBe("SN-12345"); + write.Quality.ShouldBe(OpcUaQuality.Good); + write.Ts.ShouldBe(Ts); + }, duration: Timeout); + } + + /// + /// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a + /// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new + /// revision, same d1 → EQ-1 binding) re-runs PushDesiredSubscriptions — which clears the + /// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts + /// the cached discovered plan. Asserts the sink records the FixedTree EnsureVariable AGAIN + /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL + /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). + /// + [Fact] + public void Discovered_node_and_value_survive_a_redeploy() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, + (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); + + var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId); + + host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); + + // First injection: capture the mapped NodeId once it has materialised on the sink. + string fixedTreeNodeId = null!; + AwaitAssert(() => + { + var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName); + v.NodeId.ShouldNotBeNull(); + fixedTreeNodeId = v.NodeId; + }, duration: Timeout); + + // First value flows Good (pre-redeploy baseline). + host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts)); + AwaitAssert( + () => sink.Values.ShouldContain(x => x.NodeId == fixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good), + duration: Timeout); + + var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == fixedTreeNodeId); + + // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions + // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan. + var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, + (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); + host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + // (a) The cached discovered plan was RE-MATERIALISED at the SAME NodeId after the redeploy rebuild. + AwaitAssert( + () => sink.Variables.Count(x => x.NodeId == fixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore), + duration: Timeout); + + // (b) A value published AFTER the redeploy STILL routes to the mapped NodeId and lands Good — the + // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it). + var tsAfter = Ts.AddSeconds(5); + host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter)); + AwaitAssert( + () => sink.Values.ShouldContain(x => x.NodeId == fixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good), + duration: Timeout); + } + + /// Spawns the real chain — recording sink → real → real + /// (the host's opcUaPublishActor seam) → real + /// backed by a — dispatches the + /// deployment, and waits for the Applied ACK so _lastComposition + the live child + the initial + /// subscribe pass have completed before discovery is injected. The publish actor is wired with the + /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable) + /// and the only EnsureVariable traffic is the discovered-node materialise itself. + private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor( + IDbContextFactory db, DeploymentId deploymentId) + { + var coordinator = CreateTestProbe(); + var vtHost = CreateTestProbe(); + + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + + var host = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + driverFactory: new SubscribingDriverFactory("Modbus"), + localRoles: new HashSet { "driver" }, + opcUaPublishActor: publish, + virtualTagHostOverride: vtHost.Ref)); + + host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + return (host, sink, coordinator); + } + + /// Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project + /// equipment tags + a real (non-stubbed) child for each driver + /// (mirrors DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags). An authored value tag + /// both sets _lastComposition and binds the driver → equipment (the only way the host resolves the + /// equipment a discovered node grafts under). + private static DeploymentId SeedDeploymentWithEquipmentTags( + IDbContextFactory db, RevisionHash rev, + params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) + { + var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); + + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 + }, + DriverInstances = driverIds.Select(d => new + { + DriverInstanceRowId = Guid.NewGuid(), + DriverInstanceId = d, + Name = d, + DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) + Enabled = true, + DriverConfig = "{}", + NamespaceId = "ns-eq", + }).ToArray(), + Tags = tags.Select((t, i) => new + { + TagId = $"tag-{i}", + EquipmentId = t.Equip, + DriverInstanceId = t.Driver, + Name = t.Name, + FolderPath = t.Folder, + DataType = "Double", + TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), + }).ToArray(), + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + + /// Factory producing a single shared for the supported + /// type, so a real (non-stubbed) child is spawned for the driver and + /// the host's subscribe path is exercised (mirrors + /// DriverHostActorDiscoveryTests.SubscribingDriverFactory). + private sealed class SubscribingDriverFactory : IDriverFactory + { + private readonly string _supportedType; + private readonly SubscribableStubDriver _driver = new(); + public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } + + /// + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => + string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; + + /// + public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; + } + + /// Recording — captures the EnsureFolder / EnsureVariable / + /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can + /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the + /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread). + private sealed class RecordingSink : IOpcUaAddressSpaceSink + { + private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new(); + private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new(); + private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new(); + private readonly ConcurrentQueue _modelChanges = new(); + + /// Gets a snapshot of the recorded EnsureFolder calls. + public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList(); + /// Gets a snapshot of the recorded EnsureVariable calls. + public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList(); + /// Gets a snapshot of the recorded WriteValue calls. + public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList(); + /// Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements. + public List ModelChanges => _modelChanges.ToList(); + /// Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback). + public int RebuildCalls; + + /// Records a live-value write. + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); + + /// No-op: alarm writes are not exercised by this suite. + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + + /// No-op: alarm materialise is not exercised by this suite. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + + /// Records an EnsureFolder call. + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); + + /// Records an EnsureVariable call. + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); + + /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). + public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); + + /// Records a NodeAdded model-change announcement. + public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId); + } +}