From da02784feb54c250c30747e156d7a42c326ec5a6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Jan 2026 01:33:15 -0500 Subject: [PATCH] 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 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 --- .../Contracts/IDataUpdateRepository.cs | 9 +- .../HealthChecks/DataSyncHealthCheck.cs | 2 +- .../Services/DataUpdateRepository.cs | 56 ++++- .../DataSyncHealthCheckTests.cs | 22 +- .../Services/DataUpdateRepositoryTests.cs | 232 ++++++++++++++++++ 5 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 NEW/tests/JdeScoping.DataSync.Tests/Services/DataUpdateRepositoryTests.cs diff --git a/NEW/src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs b/NEW/src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs index 8dad4a6..1cee140 100644 --- a/NEW/src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs +++ b/NEW/src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs @@ -64,9 +64,16 @@ public interface IDataUpdateRepository /// /// Gets sync status for health check purposes. /// + /// + /// Optional dictionary of custom intervals per table/updateType. + /// Key format: "{TableName}_{UpdateType}" where UpdateType is the numeric enum value (e.g., "MisData_3" for Mass). + /// Value: interval in minutes. + /// /// Cancellation token. /// List of table sync status records. - Task> GetSyncStatusAsync(CancellationToken cancellationToken = default); + Task> GetSyncStatusAsync( + Dictionary? customIntervals = null, + CancellationToken cancellationToken = default); } /// diff --git a/NEW/src/JdeScoping.DataSync/HealthChecks/DataSyncHealthCheck.cs b/NEW/src/JdeScoping.DataSync/HealthChecks/DataSyncHealthCheck.cs index a85f5bf..81179a5 100644 --- a/NEW/src/JdeScoping.DataSync/HealthChecks/DataSyncHealthCheck.cs +++ b/NEW/src/JdeScoping.DataSync/HealthChecks/DataSyncHealthCheck.cs @@ -25,7 +25,7 @@ public class DataSyncHealthCheck : IHealthCheck { try { - var statuses = await _repository.GetSyncStatusAsync(cancellationToken); + var statuses = await _repository.GetSyncStatusAsync(customIntervals: null, cancellationToken); var data = new Dictionary(); foreach (var status in statuses) diff --git a/NEW/src/JdeScoping.DataSync/Services/DataUpdateRepository.cs b/NEW/src/JdeScoping.DataSync/Services/DataUpdateRepository.cs index b9d08e9..3d21263 100644 --- a/NEW/src/JdeScoping.DataSync/Services/DataUpdateRepository.cs +++ b/NEW/src/JdeScoping.DataSync/Services/DataUpdateRepository.cs @@ -158,7 +158,9 @@ WHERE StartDT < DATEADD(DAY, -@retentionDays, GETUTCDATE())"; } /// - public async Task> GetSyncStatusAsync(CancellationToken cancellationToken = default) + public async Task> GetSyncStatusAsync( + Dictionary? customIntervals = null, + CancellationToken cancellationToken = default) { const string sql = @" WITH LastSuccessful AS ( @@ -182,16 +184,18 @@ FROM LastSuccessful"; (UpdateTypes)r.UpdateType, r.LastSuccessfulSync, r.LastSuccessfulSync.HasValue, - GetExpectedInterval((UpdateTypes)r.UpdateType), - IsOverdue(r.LastSuccessfulSync, (UpdateTypes)r.UpdateType), + GetExpectedInterval(r.TableName, (UpdateTypes)r.UpdateType, customIntervals), + IsOverdue(r.LastSuccessfulSync, r.TableName, (UpdateTypes)r.UpdateType, customIntervals), r.RecentFailures)) .ToList(); } /// - /// Gets the expected interval in minutes for an update type. + /// Gets the default interval in minutes for an update type. /// - private static int GetExpectedInterval(UpdateTypes updateType) + /// The update type. + /// The default interval in minutes. + public static int GetDefaultInterval(UpdateTypes updateType) { return updateType switch { @@ -202,20 +206,54 @@ FROM LastSuccessful"; }; } + /// + /// Gets the expected interval in minutes for a table and update type. + /// Uses custom interval if provided, otherwise falls back to default. + /// + /// The table name. + /// The update type. + /// Optional dictionary of custom intervals per table/updateType. + /// The expected interval in minutes. + public static int GetExpectedInterval( + string tableName, + UpdateTypes updateType, + Dictionary? customIntervals) + { + if (customIntervals is not null) + { + var key = $"{tableName}_{(int)updateType}"; + if (customIntervals.TryGetValue(key, out var customInterval)) + { + return customInterval; + } + } + + return GetDefaultInterval(updateType); + } + /// /// Checks if a sync is overdue based on last successful sync time. /// - private static bool IsOverdue(DateTime? lastSync, UpdateTypes updateType) + /// The last successful sync time. + /// The table name. + /// The update type. + /// Optional dictionary of custom intervals per table/updateType. + /// True if the sync is overdue; otherwise, false. + public static bool IsOverdue( + DateTime? lastSync, + string tableName, + UpdateTypes updateType, + Dictionary? customIntervals) { if (!lastSync.HasValue) { return true; } - var expectedInterval = GetExpectedInterval(updateType); + var expectedInterval = GetExpectedInterval(tableName, updateType, customIntervals); var grace = expectedInterval * 0.5; // 50% grace period - var overdueTreshold = DateTime.UtcNow.AddMinutes(-(expectedInterval + grace)); + var overdueThreshold = DateTime.UtcNow.AddMinutes(-(expectedInterval + grace)); - return lastSync.Value < overdueTreshold; + return lastSync.Value < overdueThreshold; } } diff --git a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncHealthCheckTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncHealthCheckTests.cs index 22b0425..4634448 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncHealthCheckTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncHealthCheckTests.cs @@ -36,7 +36,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -59,7 +59,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -86,7 +86,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -108,7 +108,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -130,7 +130,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("Item", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -156,7 +156,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("Lot", UpdateTypes.Mass, isOverdue: false, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -171,7 +171,7 @@ public class DataSyncHealthCheckTests public async Task CheckHealthAsync_RepositoryThrows_ReturnsUnhealthy() { // Arrange - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .ThrowsAsync(new Exception("Database connection failed")); // Act @@ -199,7 +199,7 @@ public class DataSyncHealthCheckTests new("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), true, 1440, false, 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -225,7 +225,7 @@ public class DataSyncHealthCheckTests new("WorkOrder", UpdateTypes.Mass, null, false, 10080, true, 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -244,7 +244,7 @@ public class DataSyncHealthCheckTests CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0) }; - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(statuses); // Act @@ -262,7 +262,7 @@ public class DataSyncHealthCheckTests public async Task CheckHealthAsync_EmptyStatusList_ReturnsHealthy() { // Arrange: No tables configured - _repository.GetSyncStatusAsync(Arg.Any()) + _repository.GetSyncStatusAsync(Arg.Any?>(), Arg.Any()) .Returns(new List()); // Act diff --git a/NEW/tests/JdeScoping.DataSync.Tests/Services/DataUpdateRepositoryTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/Services/DataUpdateRepositoryTests.cs new file mode 100644 index 0000000..6d7c39b --- /dev/null +++ b/NEW/tests/JdeScoping.DataSync.Tests/Services/DataUpdateRepositoryTests.cs @@ -0,0 +1,232 @@ +using System.Data; +using Dapper; +using JdeScoping.Core.Models; +using JdeScoping.Core.Models.Enums; +using JdeScoping.DataAccess.Interfaces; +using JdeScoping.DataSync.Contracts; +using JdeScoping.DataSync.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; + +namespace JdeScoping.DataSync.Tests.Services; + +/// +/// Unit tests for DataUpdateRepository. +/// Tests the custom interval functionality for GetSyncStatusAsync. +/// +public class DataUpdateRepositoryTests +{ + private readonly IDbConnectionFactory _connectionFactory; + private readonly DataUpdateRepository _sut; + + public DataUpdateRepositoryTests() + { + _connectionFactory = Substitute.For(); + _sut = new DataUpdateRepository( + _connectionFactory, + NullLogger.Instance); + } + + #region GetSyncStatusAsync Custom Intervals + + [Fact] + public void GetSyncStatusAsync_WithoutCustomIntervals_UsesDefaultIntervals() + { + // This test verifies backward compatibility - method should work without custom intervals + // The expected defaults are: + // - Hourly: 60 minutes + // - Daily: 1440 minutes (24 hours) + // - Mass: 10080 minutes (7 days) + + // Arrange - Get expected intervals from static helper + var hourlyInterval = DataUpdateRepository.GetDefaultInterval(UpdateTypes.Hourly); + var dailyInterval = DataUpdateRepository.GetDefaultInterval(UpdateTypes.Daily); + var massInterval = DataUpdateRepository.GetDefaultInterval(UpdateTypes.Mass); + + // Assert expected defaults + hourlyInterval.ShouldBe(60); + dailyInterval.ShouldBe(1440); + massInterval.ShouldBe(10080); + } + + [Fact] + public void GetExpectedInterval_WithCustomIntervals_ReturnsCustomValue() + { + // Arrange - UpdateTypes.Mass = 3, UpdateTypes.Daily = 2 + var customIntervals = new Dictionary + { + { "MisData_3", 100800 }, // Mass = 3 + { "WorkOrder_2", 120 } // Daily = 2 + }; + + // Act + var misDataMassInterval = DataUpdateRepository.GetExpectedInterval( + "MisData", + UpdateTypes.Mass, + customIntervals); + + var workOrderDailyInterval = DataUpdateRepository.GetExpectedInterval( + "WorkOrder", + UpdateTypes.Daily, + customIntervals); + + // Assert + misDataMassInterval.ShouldBe(100800); + workOrderDailyInterval.ShouldBe(120); + } + + [Fact] + public void GetExpectedInterval_WithNoMatchingCustomInterval_ReturnsDefault() + { + // Arrange - UpdateTypes.Mass = 3 + var customIntervals = new Dictionary + { + { "MisData_3", 100800 } // Only MisData_Mass has custom interval + }; + + // Act - WorkOrder_Daily should fall back to default + var workOrderDailyInterval = DataUpdateRepository.GetExpectedInterval( + "WorkOrder", + UpdateTypes.Daily, + customIntervals); + + // Assert - Should return default 1440 for Daily + workOrderDailyInterval.ShouldBe(1440); + } + + [Fact] + public void GetExpectedInterval_WithNullCustomIntervals_ReturnsDefault() + { + // Act + var massInterval = DataUpdateRepository.GetExpectedInterval( + "MisData", + UpdateTypes.Mass, + null); + + // Assert - Should return default 10080 for Mass + massInterval.ShouldBe(10080); + } + + [Fact] + public void GetExpectedInterval_WithEmptyCustomIntervals_ReturnsDefault() + { + // Arrange + var customIntervals = new Dictionary(); + + // Act + var hourlyInterval = DataUpdateRepository.GetExpectedInterval( + "WorkOrder", + UpdateTypes.Hourly, + customIntervals); + + // Assert - Should return default 60 for Hourly + hourlyInterval.ShouldBe(60); + } + + [Fact] + public void IsOverdue_WithCustomInterval_UsesCustomValue() + { + // Arrange - Custom interval of 100800 minutes (70 days) + // UpdateTypes.Mass = 3 + var customIntervals = new Dictionary + { + { "MisData_3", 100800 } + }; + + // Default Mass interval is 10080 min (7 days) + 50% grace = 10.5 days + // A sync 15 days ago would be overdue with default (15 > 10.5) + // but NOT overdue with custom 70-day interval + 50% grace = 105 days (15 < 105) + var lastSync = DateTime.UtcNow.AddDays(-15); + + // Act + var isOverdueWithCustom = DataUpdateRepository.IsOverdue( + lastSync, + "MisData", + UpdateTypes.Mass, + customIntervals); + + var isOverdueWithDefault = DataUpdateRepository.IsOverdue( + lastSync, + "MisData", + UpdateTypes.Mass, + null); + + // Assert + isOverdueWithCustom.ShouldBeFalse(); // 15 days < 70 days + 50% grace = 105 days + isOverdueWithDefault.ShouldBeTrue(); // 15 days > 7 days + 50% grace = 10.5 days + } + + [Fact] + public void IsOverdue_WithNoLastSync_ReturnsTrue() + { + // Act + var isOverdue = DataUpdateRepository.IsOverdue( + null, + "WorkOrder", + UpdateTypes.Daily, + null); + + // Assert + isOverdue.ShouldBeTrue(); + } + + [Fact] + public void IsOverdue_WithRecentSync_ReturnsFalse() + { + // Arrange - Sync completed 5 minutes ago for Hourly (60 min interval) + var lastSync = DateTime.UtcNow.AddMinutes(-5); + + // Act + var isOverdue = DataUpdateRepository.IsOverdue( + lastSync, + "WorkOrder", + UpdateTypes.Hourly, + null); + + // Assert + isOverdue.ShouldBeFalse(); + } + + [Fact] + public void IsOverdue_WithOldSync_ReturnsTrue() + { + // Arrange - Sync completed 3 hours ago for Hourly (60 min + 50% grace = 90 min) + var lastSync = DateTime.UtcNow.AddHours(-3); + + // Act + var isOverdue = DataUpdateRepository.IsOverdue( + lastSync, + "WorkOrder", + UpdateTypes.Hourly, + null); + + // Assert + isOverdue.ShouldBeTrue(); + } + + #endregion + + #region Constructor Tests + + [Fact] + public void Constructor_WithNullConnectionFactory_ThrowsArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new DataUpdateRepository(null!, NullLogger.Instance)); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Arrange + var connectionFactory = Substitute.For(); + + // Act & Assert + Should.Throw(() => + new DataUpdateRepository(connectionFactory, null!)); + } + + #endregion +}