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