using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; 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.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// R03:3.1 — the admin singleton's mailbox used to be suspended for the whole of every /// ReceiveAsync: one Test Connect against a black-holed PLC (60 s clamp) or one large deploy /// compose stalled every other admin operation fleet-wide, alarm acks included. Both now run off the /// actor thread; only the "is one in flight" check stays serialised. /// public sealed class AdminOperationsActorMailboxTests : ControlPlaneActorTestBase { private static readonly TimeSpan AckBudget = TimeSpan.FromSeconds(1); /// An alarm ack completes in under a second while a 60 s Test Connect is still pending. [Fact] public void Alarm_ack_completes_while_a_60s_test_connect_is_pending() { var dbFactory = NewInMemoryDbFactory(); var coordinator = CreateTestProbe("coord"); var probe = new BlockingProbe(); var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe })); var connectClient = CreateTestProbe("connect-client"); connectClient.Send(actor, new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 60, Guid.NewGuid())); probe.Started.Wait(PresenceBudget).ShouldBeTrue("the probe must be in flight before the ack is sent"); actor.Tell(new AcknowledgeAlarmCommand("alarm-1", "op", null, CorrelationId.NewId())); var ack = ExpectMsg(AckBudget); ack.Ok.ShouldBeTrue(); // Positive control: the probe reply still lands, with the right sender, once the probe returns. connectClient.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); probe.Release(new DriverProbeResult(true, null, TimeSpan.FromMilliseconds(5))); var result = connectClient.ExpectMsg(PresenceBudget); result.Ok.ShouldBeTrue(); result.LatencyMs.ShouldNotBeNull(); } /// A probe that times out still answers its own sender with the clamped timeout message. [Fact] public void Test_connect_timeout_is_reported_to_its_sender() { var dbFactory = NewInMemoryDbFactory(); var coordinator = CreateTestProbe("coord"); var probe = new BlockingProbe(); // never released: the CTS fires at the 1 s clamp floor var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe })); actor.Tell(new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 1, Guid.NewGuid())); var result = ExpectMsg(PresenceBudget); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message.ShouldContain("timed out"); } /// /// The deploy compose (snapshot + validate + gzip + row write) runs off the actor: an alarm ack /// completes while the compose is parked on a gated DB context, and the deploy still completes /// (Accepted + dispatched) once the gate opens. /// [Fact] public void Alarm_ack_completes_while_a_deploy_compose_is_in_flight() { var gated = new GatedDbFactory(NewInMemoryDbFactory()); var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty())); var deployer = CreateTestProbe("deployer"); gated.Hold(); deployer.Send(actor, new StartDeployment("joe", CorrelationId.NewId())); gated.Entered.Wait(PresenceBudget).ShouldBeTrue("the compose must be parked before the ack is sent"); actor.Tell(new AcknowledgeAlarmCommand("alarm-2", "op", null, CorrelationId.NewId())); ExpectMsg(AckBudget).Ok.ShouldBeTrue(); // Positive control: the deploy completes once released. deployer.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); gated.Release(); deployer.ExpectMsg(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted); coordinator.ExpectMsg(PresenceBudget); } /// /// Only the in-flight check is serialised — and it must still catch a second request that arrives /// while the first compose has not yet written its row (the DB alone would let both through). /// [Fact] public void Second_StartDeployment_during_an_in_flight_compose_is_refused() { var gated = new GatedDbFactory(NewInMemoryDbFactory()); var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty())); var first = CreateTestProbe("first"); var second = CreateTestProbe("second"); gated.Hold(); first.Send(actor, new StartDeployment("joe", CorrelationId.NewId())); gated.Entered.Wait(PresenceBudget).ShouldBeTrue(); second.Send(actor, new StartDeployment("kim", CorrelationId.NewId())); second.ExpectMsg(AckBudget).Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight); gated.Release(); first.ExpectMsg(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted); coordinator.ExpectMsg(PresenceBudget); } private sealed class BlockingProbe : IDriverProbe { private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); public ManualResetEventSlim Started { get; } = new(); public string DriverType => "Blocking"; public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { Started.Set(); return await _tcs.Task.WaitAsync(ct); } public void Release(DriverProbeResult result) => _tcs.TrySetResult(result); } /// /// Passes synchronous context creation through (the serialised in-flight check) and parks the /// asynchronous one (the off-actor compose) on a gate while is in effect. /// private sealed class GatedDbFactory(IDbContextFactory inner) : IDbContextFactory { private volatile TaskCompletionSource? _gate; public ManualResetEventSlim Entered { get; } = new(); public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); public void Release() => _gate?.TrySetResult(); public OtOpcUaConfigDbContext CreateDbContext() => inner.CreateDbContext(); public async Task CreateDbContextAsync(CancellationToken cancellationToken = default) { Entered.Set(); if (_gate is { } gate) await gate.Task.WaitAsync(cancellationToken); return inner.CreateDbContext(); } } }