using JdeScoping.Core.Models.Enums; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; namespace JdeScoping.DataSync.Tests; /// /// Unit tests for DataSyncHealthCheck. /// Tests health check scenarios: Healthy, Degraded, Unhealthy. /// public class DataSyncHealthCheckTests { private readonly IDataUpdateRepository _repository; private readonly DataSyncHealthCheck _sut; public DataSyncHealthCheckTests() { _repository = Substitute.For(); _sut = new DataSyncHealthCheck(_repository); } #region Healthy Scenarios [Fact] public async Task CheckHealthAsync_AllSyncsCurrent_ReturnsHealthy() { // Arrange var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 0), CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: false, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Healthy); result.Description.ShouldBe("All syncs current"); } [Fact] public async Task CheckHealthAsync_SlightlyOverdueButProgressing_ReturnsHealthyWithNote() { // Arrange: Some tables slightly overdue but no failures var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 0), CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert: With only 1 of 4 overdue (< half), still healthy result.Status.ShouldBe(HealthStatus.Healthy); result.Description!.ShouldContain("slightly overdue"); } #endregion #region Degraded Scenarios [Fact] public async Task CheckHealthAsync_MajorityOverdue_ReturnsDegraded() { // Arrange: More than half of tables overdue var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: true, recentFailures: 0), CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: true, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Degraded); result.Description!.ShouldContain("overdue"); } [Fact] public async Task CheckHealthAsync_SingleRecentFailure_ReturnsDegraded() { // Arrange: One table with recent failures var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 1), CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: false, recentFailures: 0), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Degraded); result.Description!.ShouldContain("failures"); } [Fact] public async Task CheckHealthAsync_TwoTablesWithFailures_ReturnsDegraded() { // Arrange: Two tables with failures (at threshold) var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 2), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 1), CreateSyncStatus("Item", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Degraded); } #endregion #region Unhealthy Scenarios [Fact] public async Task CheckHealthAsync_MultipleRecentFailures_ReturnsUnhealthy() { // Arrange: More than 2 tables with recent failures var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 1), CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 1), CreateSyncStatus("Item", UpdateTypes.Mass, isOverdue: false, recentFailures: 1), CreateSyncStatus("Lot", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Unhealthy); result.Description!.ShouldContain("Multiple recent sync failures"); } [Fact] public async Task CheckHealthAsync_RepositoryThrows_ReturnsUnhealthy() { // Arrange _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .ThrowsAsync(new Exception("Database connection failed")); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Unhealthy); result.Description.ShouldBe("Unable to check sync status"); result.Exception.ShouldNotBeNull(); result.Exception.Message.ShouldBe("Database connection failed"); } #endregion #region Diagnostic Data Scenarios [Fact] public async Task CheckHealthAsync_IncludesPerTableDiagnostics() { // Arrange var now = DateTime.UtcNow; var statuses = new List { new("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), true, 10080, false, 0), new("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), true, 1440, false, 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Data.ShouldNotBeNull(); result.Data.ShouldContainKey("WorkOrder_Mass_LastSync"); result.Data.ShouldContainKey("WorkOrder_Mass_Status"); result.Data.ShouldContainKey("WorkOrder_Mass_RecentFailures"); result.Data.ShouldContainKey("WorkOrder_Daily_LastSync"); result.Data.ShouldContainKey("TotalTables"); result.Data.ShouldContainKey("OverdueCount"); result.Data.ShouldContainKey("FailedCount"); } [Fact] public async Task CheckHealthAsync_NeverSynced_ShowsNeverInDiagnostics() { // Arrange var statuses = new List { new("WorkOrder", UpdateTypes.Mass, null, false, 10080, true, 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Data["WorkOrder_Mass_LastSync"].ShouldBe("Never"); } [Fact] public async Task CheckHealthAsync_OverdueTable_ShowsOverdueInDiagnostics() { // Arrange var statuses = new List { CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0) }; _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Data["WorkOrder_Daily_Status"].ShouldBe("Overdue"); } #endregion #region Edge Cases [Fact] public async Task CheckHealthAsync_EmptyStatusList_ReturnsHealthy() { // Arrange: No tables configured _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(new List()); // Act var result = await _sut.CheckHealthAsync(new HealthCheckContext()); // Assert result.Status.ShouldBe(HealthStatus.Healthy); } #endregion #region Helper Methods private static TableSyncStatus CreateSyncStatus( string tableName, UpdateTypes updateType, bool isOverdue, int recentFailures) { return new TableSyncStatus( TableName: tableName, UpdateType: updateType, LastSyncTime: DateTime.UtcNow.AddHours(-1), WasSuccessful: recentFailures == 0, ExpectedIntervalMinutes: 1440, IsOverdue: isOverdue, RecentFailures: recentFailures); } #endregion }