using System.Collections.Concurrent; using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; 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.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; /// /// Verifies the inbound operator-write routing wired into : a /// for a materialised equipment-variable NodeId resolves /// the NodeId → (DriverInstanceId, FullName) reverse map (built alongside the forward map in /// PushDesiredSubscriptions), gates on this node being the driver PRIMARY (reusing the same /// RedundancyStateChanged signal the alarm-emit gate uses), forwards a /// carrying the driver-side FullName to the /// right driver child, and replies a to the asker. /// /// /// Drives a real apply through the existing harness (same artifact shape as /// DriverHostActorLiveValueTests) so the reverse map is populated authentically and a real /// (non-stubbed) child is spawned. The child is backed by a /// recording driver so the test can observe the forwarded write and assert /// the no-write case on the secondary. There is no test seam to inject a TestProbe as a /// driver child, so this end-to-end approach (recording driver) is the closest faithful test the /// harness allows. /// /// public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-wr-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); /// On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to /// the right driver child (observed via the recording driver) and replies NodeWriteResult(true). [Fact] public void Primary_routes_write_to_driver_by_full_name_and_replies_success() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); // One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed". var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics). var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeTrue(); // The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId). AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); recorder.Writes[0].FullReference.ShouldBe("40001"); recorder.Writes[0].Value.ShouldBe(123.0); }, duration: Timeout); } /// On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the /// driver receives NO write — the primary gate fires before the reverse-map lookup. [Fact] public void Secondary_rejects_write_and_does_not_forward_to_driver() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Force this node Secondary so the primary gate rejects. actor.Tell(new RedundancyStateChanged( new[] { new NodeRedundancyState(TestNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); result.Reason.ShouldBe("not primary"); // No write reached the driver — the gate short-circuited before the reverse-map lookup. recorder.Writes.ShouldBeEmpty(); } /// An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing. [Fact] public void Unknown_node_id_replies_failure() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); recorder.Writes.ShouldBeEmpty(); } /// A protocol TagConfig blob with no FullName key routes by the equipment NodeId, and /// the forwarded wire-ref is the raw blob verbatim. ExtractTagFullName falls back to the raw /// blob string when no top-level FullName property is present, so the reverse map keys on /// (DriverInstanceId, <raw-blob>) and the driver receives that exact string as its /// WriteRequest.FullReference — not a FullName value extracted from the blob. [Fact] public void Primary_routes_write_for_raw_protocol_blob_tag() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); // Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config). // ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the // wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference. var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA, equip: "eq-2", driver: "drv-2", name: "torque"); var actor = SpawnHostAndApply(db, deploymentId, recorder); // Local role unknown ⇒ treated as Primary ⇒ write allowed. var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeTrue(); // The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because // there is no top-level "FullName" key in the blob. AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); recorder.Writes[0].FullReference.ShouldBe(rawBlobString); recorder.Writes[0].Value.ShouldBe(456.0); }, duration: Timeout); } /// A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail /// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into /// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose /// CreateDbContext throws on bootstrap (the same fall-through to Become(Stale) production uses). [Fact] public void Stale_host_fast_fails_route_node_write() { // A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale). var db = new ThrowingDbFactory(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" })); var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); var result = asker.ExpectMsg(TimeSpan.FromSeconds(2)); result.Success.ShouldBeFalse(); result.Reason.ShouldNotBeNull(); result.Reason!.ShouldContain("stale"); } /// Spawns the host with the recording driver factory, dispatches the deployment, and waits /// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has /// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises /// only the write path, which doesn't depend on the publish actor. private IActorRef SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) { var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); return actor; } /// /// Seeds a Sealed deployment whose artifact carries the minimal arrays /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags. Mirrors /// DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags but also carries a /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag /// so a REAL (non-stubbed) child is spawned for the write path. /// 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; } /// /// Seeds a single-tag Sealed deployment whose tag's TagConfig is a genuine protocol-driver /// blob with no FullName key (pure Modbus wire config: /// {"region":"HoldingRegister","address":200,"dataType":"UInt16"}). Because /// ExtractTagFullName finds no top-level FullName property, it falls back to /// returning the raw blob string verbatim — that raw string becomes the /// (DriverInstanceId, <raw-blob>) reverse-map key, and the driver receives it as /// WriteRequest.FullReference. Returns both the and the exact /// raw blob string so the caller can assert the forwarded wire-ref precisely. /// private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag( IDbContextFactory db, RevisionHash rev, string equip, string driver, string name) { // Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim. var rawBlobString = JsonSerializer.Serialize( new { region = "HoldingRegister", address = 200, dataType = "UInt16" }); var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = driver, Name = driver, DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", NamespaceId = "ns-eq", }, }, Tags = new[] { new { TagId = "tag-raw", EquipmentId = equip, DriverInstanceId = driver, Name = name, FolderPath = (string?)null, DataType = "Double", // Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob. TagConfig = rawBlobString, }, }, }); 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, rawBlobString); } /// An whose CreateDbContext always throws, /// driving 's bootstrap into the catchBecome(Stale) path /// so a write can be routed at a Stale host. private sealed class ThrowingDbFactory : IDbContextFactory { /// public OtOpcUaConfigDbContext CreateDbContext() => throw new InvalidOperationException("config DB unreachable (test stub)"); } /// Factory producing a single for the supported type, whose /// recorded write list is exposed for assertions. private sealed class RecordingDriverFactory : IDriverFactory { private readonly string _supportedType; private readonly RecordingDriver _driver = new(); public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; } /// The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread). public IReadOnlyList Writes => _driver.Writes; /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) { if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; _driver.Bind(driverInstanceId, driverType); return _driver; } /// public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; } /// An + that records every write and returns Good. private sealed class RecordingDriver : IDriver, IWritable { private readonly ConcurrentQueue _writes = new(); /// public string DriverInstanceId { get; private set; } = string.Empty; /// public string DriverType { get; private set; } = string.Empty; /// The writes received so far. public IReadOnlyList Writes => _writes.ToArray(); /// Sets the identity once the factory is asked to create it. public void Bind(string id, string type) { DriverInstanceId = id; DriverType = type; } /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); /// public long GetMemoryFootprint() => 0; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { foreach (var w in writes) _writes.Enqueue(w); return Task.FromResult>( writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good } } }