using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication; using ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// /// ScadaBridge#100 item 3 / #83 / #103 across a REAL two-node central cluster built from the /// production HOCON: an invalidation published on one node's bus is delivered, through /// DistributedPubSub and the production serializer, to the other node's handlers — and /// not back to the publishing node's own handlers. /// public class CentralCacheInvalidationTwoNodeTests { private sealed class RecordingHandler : ICentralCacheInvalidationHandler { private readonly List _applied = new(); public IReadOnlyList Applied { get { lock (_applied) return _applied.ToList(); } } public void Apply(ICentralCacheInvalidation invalidation) { lock (_applied) _applied.Add(invalidation); } } private static (CentralCacheInvalidationBus Bus, RecordingHandler Handler) Wire(ActorSystem node) { var bus = new CentralCacheInvalidationBus(NullLogger.Instance); var handler = new RecordingHandler(); node.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber( bus, new ICentralCacheInvalidationHandler[] { handler })), "central-cache-invalidation"); bus.SetMediator(DistributedPubSub.Get(node).Mediator); return (bus, handler); } /// /// Every typed invalidation crosses to the peer intact; the publisher never applies its own. /// /// A task that represents the asynchronous operation. [Fact] public async Task Invalidations_ReachThePeerNode_AndNotThePublisher() { await using var cluster = await TwoNodeClusterFixture.StartAsync(); var a = Wire(cluster.NodeA); var b = Wire(cluster.NodeB); // Subscriptions spread by gossip, so probe until the peer has registered its subscriber. var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); while (b.Handler.Applied.Count == 0) { Assert.True(DateTime.UtcNow < deadline, "node B never received node A's invalidation"); a.Bus.Publish(new RoleMappingsInvalidated()); await Task.Delay(250); } a.Bus.Publish(new ExternalDefinitionsInvalidated()); a.Bus.Publish(new InboundApiMethodInvalidated("MesMoveIn")); a.Bus.Publish(new SitePskInvalidated("site-a")); deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (!b.Handler.Applied.Any(i => i is SitePskInvalidated)) { Assert.True(DateTime.UtcNow < deadline, "node B did not receive the later invalidations"); await Task.Delay(100); } var applied = b.Handler.Applied; Assert.Contains(applied, i => i is RoleMappingsInvalidated); Assert.Contains(new ExternalDefinitionsInvalidated(), applied); Assert.Contains(new InboundApiMethodInvalidated("MesMoveIn"), applied); // The PSK invalidation arrives twice during the migration window (shared + legacy topic). Assert.Contains(new SitePskInvalidated("site-a"), applied); // The publisher skips its own envelopes; only the bare legacy PSK message (no origin) // comes back to it, which is the pre-existing #96 behaviour and idempotent. await Task.Delay(500); Assert.All(a.Handler.Applied, i => Assert.IsType(i)); } }