Files
jdescopingtool/NEW/tests/JdeScoping.DataSync.Tests/DataSyncHealthCheckTests.cs
T
Joseph Doherty da02784feb feat(datasync): add custom interval support to DataUpdateRepository
Add optional customIntervals parameter to GetSyncStatusAsync to allow
per-pipeline interval overrides instead of hardcoded defaults. This
enables tables like MisData to use longer sync intervals (e.g., 70 days)
while other tables use standard intervals.

Key changes:
- IDataUpdateRepository.GetSyncStatusAsync now accepts an optional
  Dictionary<string, int> for custom intervals keyed by "TableName_UpdateType"
- GetExpectedInterval and IsOverdue made public static for testing and reuse
- Added GetDefaultInterval method for accessing default values
- Updated DataSyncHealthCheck to use new signature
- Added comprehensive unit tests for custom interval behavior
2026-01-07 01:35:28 -05:00

297 lines
10 KiB
C#

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;
/// <summary>
/// Unit tests for DataSyncHealthCheck.
/// Tests health check scenarios: Healthy, Degraded, Unhealthy.
/// </summary>
public class DataSyncHealthCheckTests
{
private readonly IDataUpdateRepository _repository;
private readonly DataSyncHealthCheck _sut;
public DataSyncHealthCheckTests()
{
_repository = Substitute.For<IDataUpdateRepository>();
_sut = new DataSyncHealthCheck(_repository);
}
#region Healthy Scenarios
[Fact]
public async Task CheckHealthAsync_AllSyncsCurrent_ReturnsHealthy()
{
// Arrange
var statuses = new List<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
new("WorkOrder", UpdateTypes.Mass, null, false, 10080, true, 0)
};
_repository.GetSyncStatusAsync(Arg.Any<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.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<Dictionary<string, int>?>(), Arg.Any<CancellationToken>())
.Returns(new List<TableSyncStatus>());
// 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
}