using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Host.Health; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// M2.14 (#28): unit tests for . /// /// The check probes each required central singleton through its local /// ClusterSingletonProxy by Asking an with a short /// bounded timeout and treating a non-null as /// "reachable". These tests exercise that probe logic directly against a TestKit /// : /// /// present + reachable proxy paths (live echo actors) → Healthy; /// a missing proxy path (ActorSelection resolves a null Subject) → Unhealthy /// naming the unreachable singleton. /// /// No WebApplicationFactory / DB / formed cluster is needed — the probe is just an /// in-process Identify round-trip, so the tests are deterministic and fast. /// public class RequiredSingletonsHealthCheckTests : TestKit { /// A minimal live actor that does nothing — its mere existence makes /// an resolve a non-null Subject (i.e. "reachable"). /// No Receive<Identify> handler is needed: Akka's /// answers every message with /// an automatically, so an empty actor at the proxy /// path is sufficient to simulate a reachable singleton. private sealed class EchoActor : ReceiveActor { } private IServiceProvider ProviderReturning(ActorSystem system) { var services = new ServiceCollection(); services.AddSingleton(system); return services.BuildServiceProvider(); } private static async Task RunAsync(RequiredSingletonsHealthCheck check) { var context = new HealthCheckContext { Registration = new HealthCheckRegistration( "required-singletons", check, failureStatus: null, tags: null), }; return await check.CheckHealthAsync(context, CancellationToken.None); } [Fact] public async Task AllRequiredSingletonProxiesReachable_ReportsHealthy() { // Create a live actor at every required proxy path so each Identify resolves // a non-null Subject. foreach (var name in RequiredSingletonsHealthCheck.RequiredSingletonProxyNames) { Sys.ActorOf(Props.Create(() => new EchoActor()), name); } var check = new RequiredSingletonsHealthCheck( ProviderReturning(Sys), NullLogger.Instance); var result = await RunAsync(check); Assert.Equal(HealthStatus.Healthy, result.Status); } [Fact] public async Task OneRequiredSingletonUnreachable_ReportsUnhealthyNamingIt() { // Create all but one proxy. The missing one's ActorSelection resolves an // ActorIdentity with a null Subject within the bounded timeout → unreachable. var missing = RequiredSingletonsHealthCheck.RequiredSingletonProxyNames[0]; foreach (var name in RequiredSingletonsHealthCheck.RequiredSingletonProxyNames) { if (name == missing) continue; Sys.ActorOf(Props.Create(() => new EchoActor()), name); } var check = new RequiredSingletonsHealthCheck( ProviderReturning(Sys), NullLogger.Instance); var result = await RunAsync(check); Assert.Equal(HealthStatus.Unhealthy, result.Status); Assert.NotNull(result.Description); Assert.Contains(missing, result.Description!); } [Fact] public async Task ActorSystemNotYetAvailable_ReportsUnhealthy_DoesNotThrow() { // Startup race: ActorSystem not yet bridged into DI. The check must map this // to Unhealthy (the node is not ready to serve) rather than throwing. var emptyProvider = new ServiceCollection().BuildServiceProvider(); var check = new RequiredSingletonsHealthCheck( emptyProvider, NullLogger.Instance); var result = await RunAsync(check); Assert.Equal(HealthStatus.Unhealthy, result.Status); } [Fact] public async Task PreCancelledToken_ReportsUnhealthy_DoesNotThrow() { // Shutdown-race path: CheckHealthAsync is called with an already-cancelled // token (e.g. host is tearing down). The check must never throw — any // OperationCanceledException from Ask must be caught and mapped to Unhealthy. foreach (var name in RequiredSingletonsHealthCheck.RequiredSingletonProxyNames) { Sys.ActorOf(Props.Create(() => new EchoActor()), name); } var check = new RequiredSingletonsHealthCheck( ProviderReturning(Sys), NullLogger.Instance); using var cts = new CancellationTokenSource(); cts.Cancel(); // already cancelled before the check runs var context = new HealthCheckContext { Registration = new HealthCheckRegistration( "required-singletons", check, failureStatus: null, tags: null), }; // Must not throw; an already-cancelled token → all probes fail → Unhealthy. var result = await check.CheckHealthAsync(context, cts.Token); Assert.Equal(HealthStatus.Unhealthy, result.Status); } }