using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Per-cluster mesh Phase 3 (Task 5) — the FetchAndCache apply path: on dispatch the node /// fetches the artifact bytes over gRPC, applies them from bytes in hand, and caches them — /// reading NO config from central SQL. A null fetch is an apply failure that keeps last-known-good /// and does not advance the revision (#485). /// public sealed class DriverHostActorFetchAndCacheTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); /// Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in. private static byte[] ArtifactBytes() => JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty() }); [Fact] public void FetchReturningGoodBytes_AppliesAndCaches_WithoutReadingCentralSql() { // No Deployment row is seeded: if the apply path read ArtifactBlob from SQL it would get empty // bytes and FAIL (#485). Reaching Applied proves the bytes came from the fetcher, not SQL. var db = NewInMemoryDbFactory(); var cache = new RecordingArtifactCache(); var fetcher = new RecordingFetcher(ArtifactBytes()); var deploymentId = DeploymentId.NewId(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, deploymentArtifactCache: cache, fetchAndCacheMode: true, artifactFetcher: fetcher)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3)); cache.Stores[0].RevisionHash.ShouldBe(RevA.Value); fetcher.Calls.ShouldBe(1); } [Fact] public void FetchReturningNull_FailsTheApply_KeepsRevisionUnchanged_AndCachesNothing() { var db = NewInMemoryDbFactory(); var cache = new RecordingArtifactCache(); var fetcher = new RecordingFetcher(bytes: null); // all endpoints down / NotFound / SHA mismatch var deploymentId = DeploymentId.NewId(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, deploymentArtifactCache: cache, fetchAndCacheMode: true, artifactFetcher: fetcher)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed); Thread.Sleep(300); cache.Stores.ShouldBeEmpty(); // The revision was NOT advanced: a re-dispatch of the SAME revision fetches AGAIN rather than // short-circuiting as "already current". (Two fetch attempts prove #485's keep-last-known-good.) actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed); fetcher.Calls.ShouldBe(2); } [Fact] public void RedispatchOfTheAppliedRevision_DoesNotFetchAgain() { var db = NewInMemoryDbFactory(); var cache = new RecordingArtifactCache(); var fetcher = new RecordingFetcher(ArtifactBytes()); var deploymentId = DeploymentId.NewId(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, deploymentArtifactCache: cache, fetchAndCacheMode: true, artifactFetcher: fetcher)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); // Re-dispatch the now-current revision: the _currentRevision guard short-circuits to an // immediate ACK without a second fetch. actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); Thread.Sleep(200); fetcher.Calls.ShouldBe(1); } /// Records fetch calls and returns canned bytes (or null for "no answer"). private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher { private int _calls; public int Calls => Volatile.Read(ref _calls); public Task FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct) { Interlocked.Increment(ref _calls); return Task.FromResult(bytes); } } /// Records every store; GetCurrent* return null (no idempotency shortcut in these tests). private sealed class RecordingArtifactCache : IDeploymentArtifactCache { private readonly Lock _gate = new(); private readonly List _stores = []; public IReadOnlyList Stores { get { lock (_gate) { return _stores.ToArray(); } } } public Task StoreAsync(string clusterId, string deploymentId, string revisionHash, byte[] artifact, CancellationToken ct = default) { lock (_gate) { _stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact)); } return Task.CompletedTask; } public Task GetCurrentAsync(string clusterId, CancellationToken ct = default) => Task.FromResult(null); public Task GetCurrentUnkeyedAsync(CancellationToken ct = default) => Task.FromResult(null); internal sealed record StoreCall( string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact); } }