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