using System.Text.Json; using Akka.Actor; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Per-cluster mesh Phase 4 — a driver-only node runs with NO ConfigDb: dbFactory is null, /// the redundant SQL ack-writes no-op (central persists the ack from the ApplyAck), and /// scripted-alarm condition state is served from the replicated LocalDb store instead of the /// ConfigDb-backed EF store. These tests drive a FetchAndCache actor with a null /// dbFactory — the shape a real driver-only node boots in. /// public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-dbless-{Guid.NewGuid():N}.db"); private readonly ServiceProvider _localDbProvider; private readonly ILocalDb _localDb; public DriverHostActorDbLessTests() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) .Build(); _localDbProvider = new ServiceCollection() .AddZbLocalDb(configuration, db => { using (var connection = db.CreateConnection()) { AlarmConditionStateSchema.Apply(connection); } db.RegisterReplicated(AlarmConditionStateSchema.StateTable); }) .BuildServiceProvider(); _localDb = _localDbProvider.GetRequiredService(); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (!disposing) return; _localDbProvider.Dispose(); SqliteConnection.ClearAllPools(); foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" }) { try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } } } /// Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in. private static byte[] ArtifactBytes() => JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty() }); private LocalDbAlarmConditionStateStore NewAlarmStore() => new(_localDb, NullLogger.Instance); private static ScriptRootLogger NewScriptRootLogger() => new(new LoggerConfiguration().CreateLogger()); /// Resolves the named child of , or null when it was not spawned. /// Safe to call once the parent has replied to an Identify — PreStart (and thus the spawn decision) /// completes before any message is processed. private IActorRef? ResolveChild(IActorRef parent, string name) { var id = Sys.ActorSelection(parent.Path / name) .Ask(new Identify(name), TimeSpan.FromSeconds(3)) .GetAwaiter().GetResult(); return id.Subject; } /// Blocks until the actor has processed a message — proving PreStart has run. private void AwaitStarted(IActorRef actor) { var id = actor.Ask(new Identify("started"), TimeSpan.FromSeconds(5)) .GetAwaiter().GetResult(); id.Subject.ShouldBe(actor); } /// /// Task 4 — a full dispatch on a DB-less actor (null dbFactory, FetchAndCache, LocalDb /// alarm store) applies and acks Applied without a NullReferenceException. The redundant SQL /// ack-writes are guarded to no-ops; central is the ack system of record. /// [Fact] public void DbLessDispatch_AppliesAndAcks_WithNoConfigDb() { var cache = new RecordingArtifactCache(); var fetcher = new RecordingFetcher(ArtifactBytes()); var deploymentId = DeploymentId.NewId(); var publish = CreateTestProbe(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( dbFactory: null, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref, scriptRootLogger: NewScriptRootLogger(), deploymentArtifactCache: cache, fetchAndCacheMode: true, artifactFetcher: fetcher, alarmStateStore: NewAlarmStore())); 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)); fetcher.Calls.ShouldBe(1); } /// /// Task 6 — a DB-less actor with a LocalDb alarm store spawns its ScriptedAlarm host (the store /// is what makes the spawn possible without a ConfigDb). /// [Fact] public void DbLessNode_WithLocalDbStore_SpawnsScriptedAlarmHost() { var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( dbFactory: null, TestNode, opcUaPublishActor: publish.Ref, scriptRootLogger: NewScriptRootLogger(), fetchAndCacheMode: true, artifactFetcher: new RecordingFetcher(ArtifactBytes()), alarmStateStore: NewAlarmStore())); AwaitStarted(actor); ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull(); } /// /// Task 6 — with neither a LocalDb store NOR a ConfigDb factory there is nowhere to persist /// condition state, so the ScriptedAlarm host is skipped (not spawned against a null store). /// [Fact] public void DbLessNode_WithNoStoreAndNoDbFactory_SkipsScriptedAlarmHost() { var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( dbFactory: null, TestNode, opcUaPublishActor: publish.Ref, scriptRootLogger: NewScriptRootLogger(), fetchAndCacheMode: true, artifactFetcher: new RecordingFetcher(ArtifactBytes()), alarmStateStore: null)); AwaitStarted(actor); ResolveChild(actor, "scripted-alarm-host").ShouldBeNull(); } /// /// Phase 4 Task 9 — the ConfigDb-backed EF fallback is retired. A DB-backed actor (non-null /// dbFactory) with no alarm store now SKIPS the ScriptedAlarm host: the wired /// IAlarmStateStore is the only source of condition-state persistence, and there is no /// longer an EF store constructed over dbFactory. /// [Fact] public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost() { var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( dbFactory: NewInMemoryDbFactory(), TestNode, opcUaPublishActor: publish.Ref, scriptRootLogger: NewScriptRootLogger(), alarmStateStore: null)); AwaitStarted(actor); ResolveChild(actor, "scripted-alarm-host").ShouldBeNull(); } /// /// Focused proof the LocalDb alarm store round-trips condition state with NO ConfigDb in play. /// [Fact] public async Task LocalDbAlarmStore_RoundTripsConditionState_WithoutConfigDb() { var store = NewAlarmStore(); var t = new DateTime(2026, 07, 22, 08, 00, 00, DateTimeKind.Utc); var state = new AlarmConditionState( AlarmId: "dbless-alarm", Enabled: AlarmEnabledState.Enabled, Active: AlarmActiveState.Inactive, Acked: AlarmAckedState.Acknowledged, Confirmed: AlarmConfirmedState.Unconfirmed, Shelving: ShelvingState.Unshelved, LastTransitionUtc: t, LastActiveUtc: null, LastClearedUtc: null, LastAckUtc: t, LastAckUser: "op", LastAckComment: "ack", LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null, Comments: System.Collections.Immutable.ImmutableList.Empty); await store.SaveAsync(state, CancellationToken.None); var loaded = await store.LoadAsync("dbless-alarm", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged); loaded.LastAckUser.ShouldBe("op"); } /// 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); } }