using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Providers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
using HealthCheckStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;
namespace Cleanuparr.Infrastructure.Tests.Health;
///
/// Basic tests for DatabaseHealthCheck.
///
public class DatabaseHealthCheckTests : IDisposable
{
private readonly ILogger _logger;
private DataContext? _dataContext;
public DatabaseHealthCheckTests()
{
_logger = Substitute.For>();
}
public void Dispose()
{
_dataContext?.Dispose();
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Arrange
string databaseName = Guid.NewGuid().ToString();
_dataContext = InMemoryContext(databaseName);
// Act
var healthCheck = new DatabaseHealthCheck(
_dataContext,
InMemoryContext(databaseName),
InMemoryContext(databaseName),
_logger);
// Assert
healthCheck.ShouldNotBeNull();
}
#endregion
#region Schema Version Tests
[Fact]
public async Task CheckHealthAsync_WithFullyMigratedDatabases_ReturnsHealthy()
{
await using MigratedSchemas schemas = await MigratedSchemas.CreateAsync();
var healthCheck = schemas.BuildHealthCheck(_logger);
HealthCheckResult result = await healthCheck.CheckHealthAsync(null!);
result.Status.ShouldBe(HealthCheckStatus.Healthy);
}
[Theory]
[InlineData("data")]
[InlineData("events")]
[InlineData("users")]
public async Task CheckHealthAsync_WhenASchemaHasMigrationsThisBuildDoesNotShip_ReturnsDegraded(string schema)
{
await using MigratedSchemas schemas = await MigratedSchemas.CreateAsync();
// What a rollback leaves behind: history rows naming a migration this build never had.
await schemas.Context(schema).Database.ExecuteSqlRawAsync(
"""INSERT INTO "__EFMigrationsHistory" ("migration_id", "product_version") VALUES ('99999999999999_FromTheFuture', '99.0.0')""");
var healthCheck = schemas.BuildHealthCheck(_logger);
HealthCheckResult result = await healthCheck.CheckHealthAsync(null!);
result.Status.ShouldBe(HealthCheckStatus.Degraded);
// An operator needs the reason.
result.Description.ShouldContain("1 migration(s)");
_logger.HasLogContaining(
LogLevel.Warning, "Database was written by a newer version").ShouldBeTrue();
}
#endregion
#region Exception Handling Tests
[Fact]
public async Task CheckHealthAsync_WhenDisposedContext_ReturnsUnhealthy()
{
// Arrange
string databaseName = Guid.NewGuid().ToString();
DataContext disposedContext = InMemoryContext(databaseName);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(
disposedContext,
InMemoryContext(databaseName),
InMemoryContext(databaseName),
_logger);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
result.Status.ShouldBe(HealthCheckStatus.Unhealthy);
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_LogsError()
{
// Arrange
string databaseName = Guid.NewGuid().ToString();
DataContext disposedContext = InMemoryContext(databaseName);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(
disposedContext,
InMemoryContext(databaseName),
InMemoryContext(databaseName),
_logger);
// Act
await healthCheck.CheckHealthAsync(null!);
// Assert
var errorCalls = _logger.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == "Log")
.Where(c => c.GetArguments().Length > 0 && c.GetArguments()[0] is LogLevel l && l == LogLevel.Error)
.ToList();
errorCalls.ShouldNotBeEmpty();
}
[Fact]
public async Task CheckHealthAsync_WhenUnhealthy_DescriptionIndicatesFailure()
{
// Arrange
string databaseName = Guid.NewGuid().ToString();
DataContext disposedContext = InMemoryContext(databaseName);
disposedContext.Dispose();
var healthCheck = new DatabaseHealthCheck(
disposedContext,
InMemoryContext(databaseName),
InMemoryContext(databaseName),
_logger);
// Act
var result = await healthCheck.CheckHealthAsync(null!);
// Assert
result.Description.ShouldContain("failed", Case.Insensitive);
}
#endregion
private static TContext InMemoryContext(string databaseName)
where TContext : DbContext
{
DbContextOptionsBuilder optionsBuilder = new();
optionsBuilder.UseInMemoryDatabase(databaseName: $"{databaseName}-{typeof(TContext).Name}");
return (TContext)Activator.CreateInstance(typeof(TContext), optionsBuilder.Options)!;
}
///
/// The three schemas the app migrates at startup, each in its own file like production.
///
private sealed class MigratedSchemas : IAsyncDisposable
{
private readonly List _databases = [];
private DataContext _data = null!;
private EventsContext _events = null!;
private UsersContext _users = null!;
public static async Task CreateAsync()
{
MigratedSchemas schemas = new();
schemas._data = await schemas.MigrateAsync("data");
schemas._events = await schemas.MigrateAsync("events");
schemas._users = await schemas.MigrateAsync("users");
return schemas;
}
public DbContext Context(string schema) => schema switch
{
"data" => _data,
"events" => _events,
"users" => _users,
_ => throw new ArgumentOutOfRangeException(nameof(schema), schema, null),
};
public DatabaseHealthCheck BuildHealthCheck(ILogger logger) =>
new(_data, _events, _users, logger);
public async ValueTask DisposeAsync()
{
await _data.DisposeAsync();
await _events.DisposeAsync();
await _users.DisposeAsync();
foreach (SqliteTestDatabase database in _databases)
{
database.Dispose();
}
}
private async Task MigrateAsync(string name)
where TContext : DbContext
{
SqliteTestDatabase database = SqliteTestDatabase.Create($"dbhealth-{name}");
_databases.Add(database);
TContext context = database.CreateContext();
await context.Database.MigrateAsync();
return context;
}
}
}