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.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
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;
///
/// LocalDb Phase 1 (Task 7) — the write half of the deployment-artifact cache.
///
///
/// What matters here is not that a store happens, but the conditions under which it must NOT:
/// a failed apply, and — less obviously — a "successful" apply whose artifact never actually
/// loaded. See ReconcileDrivers: it swallows its own DB failures, so apply success does
/// not imply a real configuration was applied.
///
public sealed class DriverHostActorArtifactCacheTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
[Fact]
public void SuccessfulApply_StoresTheArtifactInTheCache()
{
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, artifact) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" },
deploymentArtifactCache: cache));
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));
var store = cache.Stores[0];
store.DeploymentId.ShouldBe(deploymentId.ToString());
store.RevisionHash.ShouldBe(RevA.Value);
store.Artifact.ShouldBe(artifact);
}
[Fact]
public void ApplyOfAClusterScopedArtifact_StoresUnderThatClusterId()
{
// The ClusterId is not config and not on IClusterRoleInfo — it is carried in the artifact's
// Nodes[] rows. Getting this wrong keys the pair's two nodes to different cache rows, and
// they would silently stop sharing a cache entry.
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: "SITE-A");
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg(TimeSpan.FromSeconds(5));
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
cache.Stores[0].ClusterId.ShouldBe("SITE-A");
}
[Fact]
public void UnscopedArtifact_StoresUnderTheSingleClusterSentinel()
{
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg(TimeSpan.FromSeconds(5));
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
cache.Stores[0].ClusterId.ShouldBe("__single");
}
[Fact]
public void EmptyArtifact_IsNotCached()
{
// THE case that makes this cache safe to boot from. ReconcileDrivers catches its own DB
// failures, logs a warning and returns without rethrowing — so an apply can reach its
// success path, ACK Applied, and have started zero drivers. Persisting that as
// last-known-good would boot the node into an empty address space during the next outage:
// strictly worse than having no cache at all. An empty blob is the observable form of it.
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var deploymentId = DeploymentId.NewId();
using (var ctx = db.CreateDbContext())
{
ctx.Deployments.Add(new Deployment
{
DeploymentId = deploymentId.Value,
RevisionHash = RevA.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = Array.Empty(),
});
ctx.SaveChanges();
}
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
// Since #486 the apply FAILS rather than reporting a success it did not achieve: empty bytes are
// what an unreadable artifact looks like, not a legitimate no-op deployment. (A configuration that
// genuinely declares nothing still serialises to a JSON document with empty arrays.)
coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);
// The point of this test is unchanged, and now holds for two independent reasons: nothing is cached.
Thread.Sleep(500);
cache.Stores.ShouldBeEmpty();
}
[Fact]
public void AThrowingCache_DoesNotFailTheApply()
{
// By the time the cache runs, an Applied ACK has already gone to the coordinator. If a cache
// failure escaped, ApplyAndAck's catch would send a SECOND, contradictory Failed ACK for a
// deployment the fleet already believes is live.
var db = NewInMemoryDbFactory();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" },
deploymentArtifactCache: new ThrowingArtifactCache()));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// Exactly one ACK, and it says Applied. A second Failed ACK arriving here would mean the
// cache failure had corrupted the deployment protocol.
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void NoCacheConfigured_AppliesNormally()
{
// Admin-only graphs and most test harnesses pass null. Caching is skipped, nothing else changes.
var db = NewInMemoryDbFactory();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" }));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
}
///
/// Seeds a sealed deployment whose artifact optionally scopes to a
/// cluster, and returns the exact bytes stored so a round-trip can be asserted.
///
private static (DeploymentId Id, byte[] Artifact) SeedDeployment(
IDbContextFactory db, RevisionHash rev, string? clusterId)
{
object payload = clusterId is null
? new
{
DriverInstances = Array.Empty