using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Communication.Actors; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// The 60 s site-address refresh (review 2026-10-21 03/M-1): at most one DB load in flight, a /// per-load deadline so a wedged read cannot pin the refresh forever, and a monotonic sequence so a /// late (older) result never overwrites a newer one. /// public class CentralCommunicationActorAddressRefreshTests : TestKit { /// Initializes the test kit with a quieter Akka log level. public CentralCommunicationActorAddressRefreshTests() : base(@"akka.loglevel = WARNING") { } private static Site GrpcSite(string id) => new("Test " + id, id) { GrpcNodeAAddress = "http://a:8083" }; private (IActorRef Actor, ISiteCommandTransport Transport) CreateActor( ISiteRepository repo, TimeSpan? loadTimeout = null) { var services = new ServiceCollection(); services.AddScoped(_ => repo); if (loadTimeout is { } t) { services.AddSingleton(Options.Create(new CommunicationOptions { QueryTimeout = t })); } var sp = services.BuildServiceProvider(); var transport = Substitute.For(); var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport))); return (actor, transport); } /// Verifies that refresh ticks arriving while a load is in flight do not start more loads, and coalesce into one re-run. [Fact] public void RefreshWhileLoadInFlight_DoesNotStackLoads_AndCoalescesIntoOneRerun() { var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); var calls = 0; var repo = Substitute.For(); repo.GetAllSitesAsync(Arg.Any()).Returns(_ => Interlocked.Increment(ref calls) == 1 ? gate.Task : Task.FromResult>(new List { GrpcSite("site-b") })); var (actor, transport) = CreateActor(repo); AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3)); for (var i = 0; i < 5; i++) actor.Tell(new RefreshSiteAddresses()); Thread.Sleep(300); Assert.Equal(1, Volatile.Read(ref calls)); gate.SetResult(new List { GrpcSite("site-a") }); // The wedged load completes, then exactly ONE coalesced re-run picks up the newer state. AwaitAssert(() => transport.Received().ReconcileSites( Arg.Is(c => c.GrpcContacts.ContainsKey("site-b"))), TimeSpan.FromSeconds(3)); Thread.Sleep(300); Assert.Equal(2, Volatile.Read(ref calls)); } /// Verifies that a load which never completes is cancelled at its deadline so the next refresh can run. [Fact] public void HungLoad_IsCancelledAtItsDeadline_AndTheNextRefreshRuns() { var calls = 0; var repo = Substitute.For(); repo.GetAllSitesAsync(Arg.Any()).Returns(async ci => { if (Interlocked.Increment(ref calls) == 1) { await Task.Delay(Timeout.Infinite, ci.Arg()); } return (IReadOnlyList)new List { GrpcSite("site-a") }; }); var (actor, transport) = CreateActor(repo, loadTimeout: TimeSpan.FromMilliseconds(300)); AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3)); // Let the deadline expire, then a fresh tick must be able to load. Thread.Sleep(600); actor.Tell(new RefreshSiteAddresses()); AwaitAssert(() => transport.Received().ReconcileSites( Arg.Is(c => c.GrpcContacts.ContainsKey("site-a"))), TimeSpan.FromSeconds(3)); } /// Verifies that a result older than the last applied one is dropped rather than reconciled. [Fact] public void OlderResult_ArrivingAfterANewerOne_IsDropped() { var repo = Substitute.For(); repo.GetAllSitesAsync(Arg.Any()).Returns(new List()); var (actor, transport) = CreateActor(repo); static SiteAddressCacheLoaded Loaded(string site, long seq) => new( new[] { site }, new Dictionary { [site] = new("http://" + site + ":8083", null) }, seq); actor.Tell(Loaded("site-new", long.MaxValue - 1)); AwaitAssert(() => transport.Received().ReconcileSites( Arg.Is(c => c.GrpcContacts.ContainsKey("site-new"))), TimeSpan.FromSeconds(3)); actor.Tell(Loaded("site-old", long.MaxValue - 2)); Thread.Sleep(300); transport.DidNotReceive().ReconcileSites( Arg.Is(c => c.GrpcContacts.ContainsKey("site-old"))); } /// Verifies that an unexpected Status.Failure releases the overlap guard so later refreshes still load. [Fact] public void UnexpectedStatusFailure_ReleasesTheOverlapGuard() { var calls = 0; var repo = Substitute.For(); repo.GetAllSitesAsync(Arg.Any()).Returns(_ => { Interlocked.Increment(ref calls); return new TaskCompletionSource>().Task; // never completes }); var (actor, _) = CreateActor(repo); AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3)); actor.Tell(new Status.Failure(new InvalidOperationException("unexpected pipe fault"))); actor.Tell(new RefreshSiteAddresses()); AwaitCondition(() => Volatile.Read(ref calls) == 2, TimeSpan.FromSeconds(3)); } /// Verifies that a load cancelled by the actor lifecycle (stop/restart) is not logged as a Warning. [Fact] public void LoadCancelledByActorLifecycle_IsNotAWarning() { var repo = Substitute.For(); repo.GetAllSitesAsync(Arg.Any()).Returns(new List()); var (actor, _) = CreateActor(repo); // A previous incarnation's load, cancelled by its PostStop, lands on the restarted actor. EventFilter.Warning(contains: "site addresses").Expect(0, TimeSpan.FromSeconds(1), () => actor.Tell(new SiteAddressLoadFailed(1, new OperationCanceledException(), null, ActorStopping: true))); // A genuine DB failure still warns. EventFilter.Warning(contains: "site addresses").ExpectOne(TimeSpan.FromSeconds(3), () => actor.Tell(new SiteAddressLoadFailed(2, new InvalidOperationException("db down"), null))); } }