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.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// lmxopcua#868 fourth review M2: a revision a node ADOPTS without an outcome-reporting apply — Stale recovery, a
/// failed bootstrap restore — is not known to be served. A dispatch of that same revision must RE-APPLY (the
/// publish actor gets a rebuild) instead of short-circuiting a clean Applied over an empty address space.
///
public sealed class DriverHostActorUnconfirmedRevisionTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
[Fact]
public void A_revision_adopted_by_Stale_recovery_is_reapplied_on_redispatch()
{
var db = new SwitchableDbFactory(Guid.NewGuid().ToString("N"));
var deploymentId = Seed(db);
db.Down = true;
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, CreateTestProbe().Ref, localRoles: new HashSet { "driver" },
opcUaPublishActor: publish.Ref));
// Bootstrap fails (DB down) ⇒ Stale. Bring the DB back and drive the recovery tick by hand.
// Barrier: Stale answers RouteNodeWrite with a "stale" refusal, so a reply proves the host is in Stale.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken)
.Reason!.ShouldContain("stale");
db.Down = false;
actor.Tell(DriverHostActor.RetryConfigDbConnection.Instance);
AwaitAssert(() =>
{
using var ctx = db.CreateDbContext();
ctx.NodeDeploymentStates.Single(r => r.NodeId == TestNode.Value).Status.ShouldBe(NodeDeploymentStatus.Applied);
}, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); // recovery restores nothing
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
publish.FishForMessage(
m => m.DeploymentId == deploymentId && m.ReportOutcome, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
}
[Fact]
public void A_revision_whose_bootstrap_restore_failed_is_reapplied_on_redispatch()
{
var db = new SwitchableDbFactory(Guid.NewGuid().ToString("N"));
var deploymentId = Seed(db);
using (var ctx = db.CreateDbContext())
{
ctx.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied,
StartedAtUtc = DateTime.UtcNow, AppliedAtUtc = DateTime.UtcNow,
});
ctx.SaveChanges();
}
db.FailRestore = true; // the restore's artifact read throws; the bootstrap's own row read does not
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, CreateTestProbe().Ref, localRoles: new HashSet { "driver" },
opcUaPublishActor: publish.Ref));
AwaitCondition(() => db.RestoreFailures > 0, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
db.FailRestore = false;
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
publish.FishForMessage(
m => m.DeploymentId == deploymentId && m.ReportOutcome, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
}
private static DeploymentId Seed(IDbContextFactory db) =>
V3ArtifactSeeder.SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
/// In-memory ConfigDb that can be down, or fail only the bootstrap restore's artifact read.
private sealed class SwitchableDbFactory(string dbName) : IDbContextFactory
{
private volatile bool _down;
private volatile bool _failRestore;
private int _attempts;
private int _restoreFailures;
public bool Down { get => _down; set => _down = value; }
public bool FailRestore { get => _failRestore; set => _failRestore = value; }
public int Attempts => Volatile.Read(ref _attempts);
public int RestoreFailures => Volatile.Read(ref _restoreFailures);
public OtOpcUaConfigDbContext CreateDbContext()
{
Interlocked.Increment(ref _attempts);
if (_down) throw new InvalidOperationException("ConfigDb unreachable (simulated)");
if (_failRestore && new System.Diagnostics.StackTrace().ToString().Contains("RestoreApplied"))
{
Interlocked.Increment(ref _restoreFailures);
throw new InvalidOperationException("ConfigDb read failed during restore (simulated)");
}
return new OtOpcUaConfigDbContext(new DbContextOptionsBuilder()
.UseInMemoryDatabase(dbName).Options);
}
}
}