using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.Core.Models.Infrastructure; using JdeScoping.DataSync.Configuration; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using Shouldly; namespace JdeScoping.DataSync.Tests; /// /// Unit tests for ScheduleChecker service. /// public class ScheduleCheckerTests { private readonly IDataUpdateRepository _repository; private readonly IOptions _options; private readonly ScheduleChecker _sut; public ScheduleCheckerTests() { _repository = Substitute.For(); _options = Options.Create(new DataSyncOptions { LookbackMultiplier = 3, DataSources = [] }); _sut = new ScheduleChecker( _repository, _options, NullLogger.Instance); } #region Priority Tests - Mass > Daily > Hourly [Fact] public async Task GetPendingTasksAsync_WhenMassNeverRun_ReturnsMassTask() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, dailyEnabled: true, hourlyEnabled: true); _options.Value.DataSources.Add(config); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].TableName.ShouldBe("WorkOrder"); tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass); tasks[0].MinimumDt.ShouldBeNull(); // Mass updates don't have MinimumDT } [Fact] public async Task GetPendingTasksAsync_WhenMassDue_ReturnsMassOverDaily() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 60, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-120), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass } // 3 = UpdateTypes.Mass }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass); } [Fact] public async Task GetPendingTasksAsync_WhenMassNotDue_ChecksDailyAndHourly() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, // weekly dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, // Mass not due { "WorkOrder_2", lastDaily } // Daily is due (25 hrs > 1440 min) }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); } [Fact] public async Task GetPendingTasksAsync_WhenDailyDue_ReturnsDailyOverHourly() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); } [Fact] public async Task GetPendingTasksAsync_WhenOnlyHourlyDue_ReturnsHourly() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly); } #endregion #region MinimumDT Calculation with Lookback [Fact] public async Task GetPendingTasksAsync_DailySync_CalculatesMinimumDTWithLookback() { // Arrange: LookbackMultiplier = 3, daily interval = 1440 min // MinimumDT = lastDaily.EndDT - (3 * 1440) = lastDaily.EndDT - 4320 min = 3 days before lastDaily var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); tasks[0].MinimumDt.ShouldNotBeNull(); // Expected: lastDaily.EndDT - (3 * 1440 min) = lastDaily.EndDT - 3 days var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440); tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1)); } [Fact] public async Task GetPendingTasksAsync_HourlySync_UsesDailyTimestampForMinimumDT() { // Arrange: Per legacy behavior, hourly uses DAILY's timestamp for MinimumDT calculation var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly); tasks[0].MinimumDt.ShouldNotBeNull(); // Hourly uses daily's timestamp and daily's interval for lookback calculation var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440); tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1)); } [Fact] public async Task GetPendingTasksAsync_WithDifferentLookbackMultiplier_CalculatesCorrectly() { // Arrange: Test with multiplier = 5 _options.Value.LookbackMultiplier = 5; var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-5 * 1440); tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1)); } #endregion #region Disabled Table Handling [Fact] public async Task GetPendingTasksAsync_DisabledDataSource_ReturnsNoTasks() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true); config.IsEnabled = false; _options.Value.DataSources.Add(config); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldBeEmpty(); } [Fact] public async Task GetPendingTasksAsync_DisabledMassSchedule_SkipsMass() { // Arrange: Mass disabled, Daily enabled var config = CreateDataSourceConfig("WorkOrder", massEnabled: false, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; // Even with no mass ever run, if mass is disabled, should NOT require mass first // However, current logic requires mass before daily, so this tests that properly var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Should return Daily since mass is disabled but already ran before tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); } [Fact] public async Task GetPendingTasksAsync_DisabledDailySchedule_SkipsDaily() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: false, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Should return Hourly, skipping Daily tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly); } [Fact] public async Task GetPendingTasksAsync_AllSchedulesDisabled_ReturnsNoTasks() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: false, dailyEnabled: false, hourlyEnabled: false); _options.Value.DataSources.Add(config); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldBeEmpty(); } #endregion #region First Sync (No Prior Updates) Scenario [Fact] public async Task GetPendingTasksAsync_NoPriorUpdates_RequiresMassFirst() { // Arrange: Never synced before, all schedules enabled var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Must do Mass first tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass); tasks[0].MinimumDt.ShouldBeNull(); } [Fact] public async Task GetPendingTasksAsync_OnlyMassCompleted_DailyHasNullMinimumDT() { // Arrange: Mass completed, no daily yet var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Should return Daily with null MinimumDT (no prior daily to calculate from) tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); tasks[0].MinimumDt.ShouldBeNull(); } [Fact] public async Task GetPendingTasksAsync_NeverHadMass_DoesNotReturnDailyOrHourly() { // Arrange: Daily and Hourly enabled but no Mass ever run var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Only Mass should be returned - can't do daily/hourly without initial mass tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass); } #endregion #region Failed Sync Recovery [Fact] public async Task GetPendingTasksAsync_FailedMass_ReturnsMassAgain() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-5), success: false); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert: Failed mass should trigger retry regardless of interval tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass); } [Fact] public async Task GetPendingTasksAsync_FailedDaily_ReturnsDailyAgain() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddMinutes(-5), success: false); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily); } [Fact] public async Task GetPendingTasksAsync_FailedHourly_ReturnsHourlyAgain() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-1), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddMinutes(-5), success: false); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldHaveSingleItem(); tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly); } #endregion #region Multiple Tables [Fact] public async Task GetPendingTasksAsync_MultipleTables_ReturnsTasksForEach() { // Arrange var config1 = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 60); var config2 = CreateDataSourceConfig("LotUsage", massEnabled: true, massInterval: 60); _options.Value.DataSources.Add(config1); _options.Value.DataSources.Add(config2); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.Count.ShouldBe(2); tasks.ShouldContain(t => t.TableName == "WorkOrder"); tasks.ShouldContain(t => t.TableName == "LotUsage"); } [Fact] public async Task GetPendingTasksAsync_MultipleTables_DifferentSchedulesDue() { // Arrange var config1 = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440); var config2 = CreateDataSourceConfig("LotUsage", massEnabled: true, massInterval: 60); _options.Value.DataSources.Add(config1); _options.Value.DataSources.Add(config2); var now = DateTime.UtcNow; var lastMassWorkOrder = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true); var lastDailyWorkOrder = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true); var lastMassLotUsage = CreateDataUpdate("LotUsage", UpdateTypes.Mass, now.AddHours(-2), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMassWorkOrder }, { "WorkOrder_2", lastDailyWorkOrder }, { "LotUsage_3", lastMassLotUsage } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.Count.ShouldBe(2); tasks.ShouldContain(t => t.TableName == "WorkOrder" && t.UpdateType == UpdateTypes.Daily); tasks.ShouldContain(t => t.TableName == "LotUsage" && t.UpdateType == UpdateTypes.Mass); } #endregion #region Edge Cases [Fact] public async Task GetPendingTasksAsync_NothingDue_ReturnsEmptyList() { // Arrange var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, hourlyEnabled: true, hourlyInterval: 60); _options.Value.DataSources.Add(config); var now = DateTime.UtcNow; // All syncs completed recently var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-5), success: true); var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddMinutes(-5), success: true); var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddMinutes(-5), success: true); _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary { { "WorkOrder_3", lastMass }, { "WorkOrder_2", lastDaily }, { "WorkOrder_1", lastHourly } }); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldBeEmpty(); } [Fact] public async Task GetPendingTasksAsync_NoDataSources_ReturnsEmptyList() { // Arrange: No data sources configured _repository.GetLastDataUpdatesAsync(Arg.Any()) .Returns(new Dictionary()); // Act var tasks = await _sut.GetPendingTasksAsync(); // Assert tasks.ShouldBeEmpty(); } #endregion #region Helper Methods private static DataSourceConfig CreateDataSourceConfig( string tableName, bool massEnabled = false, int massInterval = 10080, bool dailyEnabled = false, int dailyInterval = 1440, bool hourlyEnabled = false, int hourlyInterval = 60) { return new DataSourceConfig { TableName = tableName, SourceSystem = "JDE", SourceData = tableName.ToUpper(), FetcherTypeName = $"Jde{tableName}Fetcher", IsEnabled = true, MassConfig = new ScheduleConfig { Enabled = massEnabled, IntervalMinutes = massInterval, PrepurgeData = true, ReIndexData = true }, DailyConfig = new ScheduleConfig { Enabled = dailyEnabled, IntervalMinutes = dailyInterval }, HourlyConfig = new ScheduleConfig { Enabled = hourlyEnabled, IntervalMinutes = hourlyInterval } }; } private static DataUpdate CreateDataUpdate( string tableName, UpdateTypes updateType, DateTime endDt, bool success) { return new DataUpdate { Id = 1, TableName = tableName, SourceSystem = "JDE", SourceData = tableName.ToUpper(), UpdateType = updateType, StartDt = endDt.AddMinutes(-5), EndDt = endDt, WasSuccessful = success, NumberRecords = success ? 1000 : -1 }; } #endregion }