da02784feb
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
297 lines
10 KiB
C#
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
|
|
}
|