using Akka.Actor; using Akka.Cluster; using Akka.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Resilience; using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Pure-DI (no fixture) coverage of the publisher's snapshot→message mapping — the only unit-testable /// seam of . The DPS Publish shell is /// verified by the R2-10 live gate; this asserts the mapping the shell publishes. /// public sealed class DriverResilienceStatusPublisherServiceTests { private static readonly DateTime Published = new(2026, 7, 13, 9, 0, 0, DateTimeKind.Utc); private static readonly DateTime Sampled = new(2026, 7, 13, 8, 59, 0, DateTimeKind.Utc); [Fact] public void BuildMessages_EmptyTracker_ReturnsEmpty() { var tracker = new DriverResilienceStatusTracker(); var messages = DriverResilienceStatusPublisherService.BuildMessages(tracker, Published); messages.ShouldBeEmpty(); } [Fact] public void BuildMessages_MapsTrackerSnapshot_OneMessagePerInstanceHost() { var tracker = new DriverResilienceStatusTracker(); // (drv-1, host-a): an open breaker with a standing failure count. tracker.RecordFailure("drv-1", "host-a", Sampled); tracker.RecordFailure("drv-1", "host-a", Sampled); tracker.RecordBreakerOpen("drv-1", "host-a", Sampled); // (drv-1, host-b): a healthy host with in-flight calls. tracker.RecordCallStart("drv-1", "host-b"); var messages = DriverResilienceStatusPublisherService.BuildMessages(tracker, Published); messages.Count.ShouldBe(2); var open = messages.Single(m => m.HostName == "host-a"); open.DriverInstanceId.ShouldBe("drv-1"); open.BreakerOpen.ShouldBeTrue(); open.ConsecutiveFailures.ShouldBe(2); open.LastBreakerOpenUtc.ShouldBe(Sampled); open.PublishedUtc.ShouldBe(Published); var healthy = messages.Single(m => m.HostName == "host-b"); healthy.BreakerOpen.ShouldBeFalse(); healthy.ConsecutiveFailures.ShouldBe(0); healthy.CurrentInFlight.ShouldBe(1); healthy.PublishedUtc.ShouldBe(Published); } /// Per-cluster mesh Phase 5 — each snapshot the publish tick sends to the /// driver-resilience-status DPS topic is also emitted into the node-local /// as a . Runs the background /// service against a single-node cluster (so DistributedPubSub resolves) for a few ticks and asserts the /// hub captured the tracked pair. [Fact] public async Task ExecuteAsync_emits_Resilience_item_per_published_snapshot() { // Plain self-joined single-node cluster (mirrors SecretsReplicationRegistrationTests — the // xunit.v3 project cannot use Akka.TestKit.Xunit2). public-hostname is load-bearing for the bind. var hocon = ConfigurationFactory.ParseString(""" akka { loglevel = WARNING actor.provider = cluster remote.dot-netty.tcp { hostname = "127.0.0.1" public-hostname = "127.0.0.1" port = 0 } cluster { roles = ["driver"] } } """); var sys = ActorSystem.Create("resil-hub-test", hocon); try { var cluster = Akka.Cluster.Cluster.Get(sys); cluster.Join(cluster.SelfAddress); var upDeadline = DateTime.UtcNow.AddSeconds(10); while (!cluster.State.Members.Any(m => m.Status == MemberStatus.Up) && DateTime.UtcNow < upDeadline) await Task.Delay(50, TestContext.Current.CancellationToken); cluster.State.Members.ShouldContain(m => m.Status == MemberStatus.Up); var tracker = new DriverResilienceStatusTracker(); tracker.RecordCallStart("drv-1", "host-a"); var hub = new CapturingHub(); var service = new DriverResilienceStatusPublisherService( tracker, () => sys, NullLogger.Instance, hub, interval: TimeSpan.FromMilliseconds(50)); await service.StartAsync(TestContext.Current.CancellationToken); try { var deadline = DateTime.UtcNow.AddSeconds(5); while (hub.Items.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(50, TestContext.Current.CancellationToken); } finally { await service.StopAsync(TestContext.Current.CancellationToken); } hub.Items.ShouldNotBeEmpty(); var item = hub.Items[0].ShouldBeOfType(); item.E.DriverInstanceId.ShouldBe("drv-1"); item.E.HostName.ShouldBe("host-a"); item.E.CurrentInFlight.ShouldBe(1); } finally { await sys.Terminate(); } } /// Fake hub recording every emit; is unused here. private sealed class CapturingHub : ITelemetryLocalHub { public List Items { get; } = new(); public void Emit(TelemetryItem item) { lock (Items) Items.Add(item); } public ITelemetrySubscription Subscribe(int boundedCapacity) => throw new NotSupportedException(); } }