Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Reflection;
using Grand.Data;
using Grand.Web.Common.Infrastructure.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks;

[TestClass]
[DoNotParallelize]
public class StartupHealthCheckTests
{
// DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache()
// can only force the cached value to false, never back to true - see DataSettingsManager.cs.
// Resetting the private static instance per test keeps "installed" and "not installed" cases
// independent instead of depending on test execution order. [DoNotParallelize] on this class
// stops these tests running concurrently with each other against that same process-wide state.
private static readonly FieldInfo InstanceField = typeof(DataSettingsManager)
.GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
"DataSettingsManager._instance field not found - has the type changed?");

private string _settingsPath = null!;

[TestInitialize]
public void Setup()
{
InstanceField.SetValue(null, null);
_settingsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt");
Comment thread
KrzysztofPajak marked this conversation as resolved.
Dismissed
DataSettingsManager.Initialize(_settingsPath);
}

[TestCleanup]
public void Cleanup()
{
InstanceField.SetValue(null, null);
if (File.Exists(_settingsPath))
File.Delete(_settingsPath);
}

private static Mock<IHostApplicationLifetime> MockLifetime(bool started)
{
var cts = new CancellationTokenSource();
Comment thread
KrzysztofPajak marked this conversation as resolved.
Dismissed
if (started) cts.Cancel();

var lifetime = new Mock<IHostApplicationLifetime>();
lifetime.Setup(l => l.ApplicationStarted).Returns(cts.Token);
return lifetime;
}

[TestMethod]
public async Task CheckHealthAsync_ApplicationNotStarted_ReturnsUnhealthy()
{
DataSettingsManager.Instance.LoadDataSettings(
new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB });

var check = new StartupHealthCheck(MockLifetime(started: false).Object);

var result = await check.CheckHealthAsync(new HealthCheckContext());

Assert.AreEqual(HealthStatus.Unhealthy, result.Status);
}

[TestMethod]
public async Task CheckHealthAsync_StartedButDatabaseNotConfigured_ReturnsUnhealthy()
{
// no connection string loaded - DatabaseIsInstalled() evaluates to false on first call
var check = new StartupHealthCheck(MockLifetime(started: true).Object);

var result = await check.CheckHealthAsync(new HealthCheckContext());

Assert.AreEqual(HealthStatus.Unhealthy, result.Status);
}

[TestMethod]
public async Task CheckHealthAsync_StartedAndDatabaseConfigured_ReturnsHealthy()
{
DataSettingsManager.Instance.LoadDataSettings(
new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB });

var check = new StartupHealthCheck(MockLifetime(started: true).Object);

var result = await check.CheckHealthAsync(new HealthCheckContext());

Assert.AreEqual(HealthStatus.Healthy, result.Status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
Expand Down Expand Up @@ -195,7 +196,16 @@ public static void UseGrandForwardedHeaders(this WebApplication application)
/// <param name="application">Builder for configuring an application's request pipeline</param>
public static void UseGrandHealthChecks(this WebApplication application)
{
application.UseHealthChecks("/health/live");
application.UseHealthChecks("/health/live", new HealthCheckOptions {
Predicate = check => check.Tags.Contains("live")
});

//intentionally does not probe MongoDB or Redis - readiness here covers only the
//application process itself. Dependency probing (DB/Redis ping) is a deliberate future
//extension, not an oversight.
application.UseHealthChecks("/health/ready", new HealthCheckOptions {
Predicate = check => check.Tags.Contains("ready")
});
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Grand.Data;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;

namespace Grand.Web.Common.Infrastructure.HealthChecks;

/// <summary>
/// Reports whether the application has finished starting and is configured with a database
/// connection. Intentionally does not probe MongoDB or Redis - readiness here covers only the
/// application process itself. Dependency probing (DB/Redis ping) is a deliberate future
/// extension, not an oversight.
/// Note: DataSettingsManager.DatabaseIsInstalled() caches its result after the first call and
/// can only be forced to false afterward, never back to true, without a process restart - so a
/// freshly-installed instance keeps returning Unhealthy here until the app restarts post-install
/// (expected: the installer already asks for a restart once setup completes).
/// </summary>
public class StartupHealthCheck : IHealthCheck
{
private readonly IHostApplicationLifetime _applicationLifetime;

public StartupHealthCheck(IHostApplicationLifetime applicationLifetime)
{
ArgumentNullException.ThrowIfNull(applicationLifetime);
_applicationLifetime = applicationLifetime;
}

public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
CancellationToken cancellationToken = default)
{
if (!_applicationLifetime.ApplicationStarted.IsCancellationRequested)
return Task.FromResult(HealthCheckResult.Unhealthy("Application has not finished starting."));

return Task.FromResult(DataSettingsManager.DatabaseIsInstalled()
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("Database connection is not configured."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.WebEncoders;
using Grand.Web.Common.Infrastructure.HealthChecks;
using StackExchange.Redis;
using System.Text.Encodings.Web;
using System.Text.Unicode;
Expand Down Expand Up @@ -267,7 +268,14 @@ public static void AddSettings(this IServiceCollection services)
public static void AddGrandHealthChecks(this IServiceCollection services)
{
var hcBuilder = services.AddHealthChecks();
hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy());
//every check registered here must carry a "live" or "ready" tag - /health/live and
// /health/ready each filter by tag, so an untagged check would silently run on neither
//liveness: process can respond to a request - never touches an external dependency
hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]);
//readiness: application finished starting and is configured. Intentionally does not probe
//MongoDB or Redis - readiness here covers only the application process itself. Dependency
//probing (DB/Redis ping) is a deliberate future extension, not an oversight.
hcBuilder.AddCheck<StartupHealthCheck>("startup", tags: ["ready"]);
}

/// <summary>
Expand Down
Loading