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
}