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);
+ }
+}