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);
}
}