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
+}