using Elsa.Workflows.Runtime.HealthChecks; using Elsa.Workflows.Runtime.Options; using Medallion.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; namespace Elsa.Workflows.Runtime.UnitTests.HealthChecks; public class ElsaDistributedLockHealthCheckTests { private static readonly TimeSpan ExpectedLockAcquisitionTimeout = TimeSpan.FromMilliseconds(250); private readonly IDistributedLockProvider _distributedLockProvider = Substitute.For(); private readonly IDistributedLock _distributedLock = Substitute.For(); private readonly ElsaDistributedLockHealthCheck _sut; public ElsaDistributedLockHealthCheckTests() { _distributedLockProvider.CreateLock(Arg.Any()).Returns(_distributedLock); _distributedLock.TryAcquireAsync(Arg.Any(), Arg.Any()) .Returns(new ValueTask(Substitute.For())); _sut = new ElsaDistributedLockHealthCheck(CreateServiceProvider(_distributedLockProvider), CreateOptions(), NullLogger.Instance); } [Fact] public async Task ReturnsHealthyWhenProbeLockCanBeAcquired() { var result = await _sut.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); Assert.Equal("distributed-locks", result.Data["category"]); _distributedLockProvider.Received(1).CreateLock(Arg.Is(x => IsProbeLockName(x))); await _distributedLock.Received(1).TryAcquireAsync(ExpectedLockAcquisitionTimeout, Arg.Any()); } [Fact] public async Task UsesUniqueProbeLockNameForEachCheck() { var lockNames = new List(); var distributedLockProvider = Substitute.For(); var distributedLock = Substitute.For(); distributedLockProvider.CreateLock(Arg.Do(lockNames.Add)).Returns(distributedLock); distributedLock.TryAcquireAsync(Arg.Any(), Arg.Any()) .Returns(new ValueTask(Substitute.For())); var sut = new ElsaDistributedLockHealthCheck(CreateServiceProvider(distributedLockProvider), CreateOptions(), NullLogger.Instance); await sut.CheckHealthAsync(new HealthCheckContext()); await sut.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(2, lockNames.Count); Assert.All(lockNames, x => Assert.DoesNotContain(Environment.MachineName, x, StringComparison.OrdinalIgnoreCase)); Assert.NotEqual(lockNames[0], lockNames[1]); Assert.All(lockNames, x => Assert.True(IsProbeLockName(x))); } [Fact] public async Task ReturnsDegradedWhenProbeLockCannotBeAcquired() { _distributedLock.TryAcquireAsync(Arg.Any(), Arg.Any()) .Returns(new ValueTask((IDistributedSynchronizationHandle?)null)); var result = await _sut.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Degraded, result.Status); Assert.Equal("distributed-locks", result.Data["category"]); } [Fact] public async Task ReturnsUnhealthyWhenProviderThrows() { _distributedLockProvider.CreateLock(Arg.Any()).Returns(_ => throw new InvalidOperationException("lock backend unavailable")); var result = await _sut.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Unhealthy, result.Status); Assert.Equal("distributed-locks", result.Data["category"]); } [Fact] public async Task ReturnsDegradedWhenProviderIsNotRegistered() { var sut = new ElsaDistributedLockHealthCheck(CreateServiceProvider(), CreateOptions(), NullLogger.Instance); var result = await sut.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Degraded, result.Status); Assert.Equal("distributed-locks", result.Data["category"]); } private static IServiceProvider CreateServiceProvider(IDistributedLockProvider? distributedLockProvider = null) { var services = new ServiceCollection(); if (distributedLockProvider != null) services.AddSingleton(distributedLockProvider); return services.BuildServiceProvider(); } private static IOptions CreateOptions() => Microsoft.Extensions.Options.Options.Create(new ElsaReadinessHealthCheckOptions { DistributedLockAcquisitionTimeout = ExpectedLockAcquisitionTimeout }); private static bool IsProbeLockName(string lockName) { const string prefix = "elsa-health-check-"; return lockName.StartsWith(prefix, StringComparison.Ordinal) && Guid.TryParseExact(lockName[prefix.Length..], "N", out _); } }