From 5ee920a3997f24342afe732fa3617bb3e545b0b1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Jan 2026 06:26:45 -0500 Subject: [PATCH] feat: complete WorkProcessor integration and bug fixes - Fix hourly lookback bug: use hourly timestamp/interval (not daily) - Update DI registrations across DataSync, DataAccess, Api layers - Add WorkProcessor config to appsettings.json - Remove deprecated DataSyncService (replaced by WorkProcessor) All 340 DataSync tests pass. Legacy bug from OLD solution now fixed. --- NEW/src/JdeScoping.Api/DependencyInjection.cs | 5 + .../DependencyInjection.cs | 2 +- .../JdeScoping.DataSync/DataSyncService.cs | 160 ----- .../DependencyInjection.cs | 14 +- .../Services/ScheduleChecker.cs | 5 +- NEW/src/JdeScoping.Host/appsettings.json | 6 + .../DataSyncServiceTests.cs | 671 ------------------ .../ScheduleCheckerTests.cs | 8 +- 8 files changed, 30 insertions(+), 841 deletions(-) delete mode 100644 NEW/src/JdeScoping.DataSync/DataSyncService.cs delete mode 100644 NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs diff --git a/NEW/src/JdeScoping.Api/DependencyInjection.cs b/NEW/src/JdeScoping.Api/DependencyInjection.cs index 799b220..be902d8 100644 --- a/NEW/src/JdeScoping.Api/DependencyInjection.cs +++ b/NEW/src/JdeScoping.Api/DependencyInjection.cs @@ -1,6 +1,8 @@ using System.Text.Json.Serialization; using JdeScoping.Api.Hubs; using JdeScoping.Api.Options; +using JdeScoping.Api.Services; +using JdeScoping.Core.Interfaces; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -36,6 +38,9 @@ public static class ApiDependencyInjection // Configure SignalR services.AddSignalR(); + // Register SignalR notification service + services.AddScoped(); + // Configure cookie authentication services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => diff --git a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs index 618b246..f24cf69 100644 --- a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs +++ b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs @@ -49,7 +49,7 @@ public static class DataAccessDependencyInjection // Register search processing services (scoped) services.AddScoped(); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/NEW/src/JdeScoping.DataSync/DataSyncService.cs b/NEW/src/JdeScoping.DataSync/DataSyncService.cs deleted file mode 100644 index 88f92ee..0000000 --- a/NEW/src/JdeScoping.DataSync/DataSyncService.cs +++ /dev/null @@ -1,160 +0,0 @@ -using JdeScoping.DataSync.Options; -using JdeScoping.DataSync.Contracts; -using JdeScoping.DataSync.Telemetry; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace JdeScoping.DataSync; - -/// -/// Background service that orchestrates data synchronization from JDE/CMS to SQL Server cache. -/// -public class DataSyncService : BackgroundService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly IOptions _options; - private readonly ILogger _logger; - private readonly DataSyncMetrics _metrics; - - private DateTime _lastPurgeCheck = DateTime.MinValue; - private readonly TimeSpan _purgeCheckInterval = TimeSpan.FromHours(24); - - /// - /// Initializes a new instance of the class. - /// - public DataSyncService( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger, - DataSyncMetrics metrics) - { - _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_options.Value.Enabled) - { - _logger.LogInformation("DataSyncService is disabled via configuration"); - return; - } - - _logger.LogInformation( - "DataSyncService starting with CheckInterval={CheckInterval}, MaxDegreeOfParallelism={MaxDegreeOfParallelism}", - _options.Value.CheckInterval, - _options.Value.MaxDegreeOfParallelism); - - // Startup: close any interrupted syncs from prior runs - await CloseOpenUpdateEntriesAsync(stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - // Create scope for this sync cycle - await using var scope = _scopeFactory.CreateAsyncScope(); - - var orchestrator = scope.ServiceProvider.GetRequiredService(); - - // Check schedules and execute pending syncs - await orchestrator.ExecutePendingSyncsAsync(stoppingToken); - - // Periodic purge of old DataUpdate records - await PurgeUpdateEntriesAsync(scope, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // Graceful shutdown - _logger.LogInformation("DataSyncService stopping gracefully"); - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in sync cycle"); - _metrics.RecordCycleError(); - } - - // Wait before next check - try - { - await Task.Delay(_options.Value.CheckInterval, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - } - - _logger.LogInformation("DataSyncService stopped"); - } - - /// - /// Closes any open update entries from interrupted prior runs. - /// - private async Task CloseOpenUpdateEntriesAsync(CancellationToken cancellationToken) - { - try - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - - var closedCount = await repository.CloseOpenUpdateEntriesAsync(cancellationToken); - - if (closedCount > 0) - { - _logger.LogWarning( - "Closed {Count} interrupted update entries from prior runs", - closedCount); - } - else - { - _logger.LogDebug("No interrupted update entries found"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to close open update entries at startup"); - // Continue starting - this is not fatal - } - } - - /// - /// Purges old DataUpdate records periodically. - /// - private async Task PurgeUpdateEntriesAsync(AsyncServiceScope scope, CancellationToken cancellationToken) - { - if (DateTime.UtcNow - _lastPurgeCheck < _purgeCheckInterval) - { - return; - } - - _lastPurgeCheck = DateTime.UtcNow; - - try - { - var repository = scope.ServiceProvider.GetRequiredService(); - var purgedCount = await repository.PurgeOldEntriesAsync( - _options.Value.PurgeRetentionDays, - cancellationToken); - - if (purgedCount > 0) - { - _logger.LogInformation( - "Purged {Count} DataUpdate records older than {Days} days", - purgedCount, - _options.Value.PurgeRetentionDays); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to purge old update entries"); - // Continue - this is not fatal - } - } -} diff --git a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs index 803ce5f..8a64d29 100644 --- a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs +++ b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs @@ -29,6 +29,12 @@ public static class DataSyncDependencyInjection .ValidateDataAnnotations() .ValidateOnStart(); + // WorkProcessor configuration with validation + services.AddOptions() + .Bind(configuration.GetSection(WorkProcessorOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + // Pipeline configuration (new ETL infrastructure) services.AddOptions() .Bind(configuration.GetSection(PipelineOptions.SectionName)); @@ -36,8 +42,8 @@ public static class DataSyncDependencyInjection // Pipeline factory (new ETL infrastructure) services.AddSingleton(); - // Register hosted service - services.AddHostedService(); + // Register hosted service (WorkProcessor combines data sync and search processing) + services.AddHostedService(); // Register core services as scoped (for parallel isolation) services.AddScoped(); @@ -45,6 +51,10 @@ public static class DataSyncDependencyInjection services.AddScoped(); services.AddScoped(); + // Register search processing services as scoped + services.AddScoped(); + services.AddScoped(); + // Register health check services.AddHealthChecks() .AddCheck("data-sync", tags: ["datasync", "background"]); diff --git a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs index c1fcf91..5dff787 100644 --- a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs +++ b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs @@ -107,11 +107,10 @@ public class ScheduleChecker : IScheduleChecker return CreateTask(config, UpdateTypes.Daily, minimumDt); } - // Check Hourly (uses Daily's last timestamp for MinimumDT calculation, per legacy behavior) + // Check Hourly if (config.HourlyConfig.Enabled && NeedsHourlySync(config, lastHourly, lastDaily, lastMass, now)) { - // Use daily update timestamp for lookback, not hourly - var minimumDt = CalculateMinimumDt(lastDaily, config.DailyConfig.IntervalMinutes); + var minimumDt = CalculateMinimumDt(lastHourly, config.HourlyConfig.IntervalMinutes); _logger.LogDebug( "Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}", diff --git a/NEW/src/JdeScoping.Host/appsettings.json b/NEW/src/JdeScoping.Host/appsettings.json index e6aa6ed..9fa379d 100644 --- a/NEW/src/JdeScoping.Host/appsettings.json +++ b/NEW/src/JdeScoping.Host/appsettings.json @@ -128,6 +128,12 @@ "UseFileDataSource": false, "FileDirectory": "DevData" }, + "WorkProcessor": { + "Enabled": true, + "WorkInterval": "00:00:05", + "SearchTimeout": "00:30:00", + "PurgeRetentionDays": 30 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs deleted file mode 100644 index 827aa2b..0000000 --- a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs +++ /dev/null @@ -1,671 +0,0 @@ -using System.Diagnostics.Metrics; -using JdeScoping.DataSync.Options; -using JdeScoping.DataSync.Contracts; -using JdeScoping.DataSync.Telemetry; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using NSubstitute; -using Shouldly; - -namespace JdeScoping.DataSync.Tests; - -/// -/// Integration tests for DataSyncService. -/// These tests verify the service lifecycle and orchestration behavior. -/// -public class DataSyncServiceTests -{ - #region Service Startup and Shutdown - - [Fact] - public async Task ExecuteAsync_WhenDisabled_ExitsImmediately() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = false - }); - - var services = new ServiceCollection(); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - var task = service.StartAsync(cts.Token); - await Task.Delay(100); // Give it time to start - - // Assert: Service should complete quickly since it's disabled - await service.StopAsync(CancellationToken.None); - task.IsCompleted.ShouldBeTrue(); - } - - [Fact] - public async Task ExecuteAsync_WhenEnabled_StartsAndCanBeStopped() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(100) - }); - - var repository = Substitute.For(); - repository.CloseOpenUpdateEntriesAsync(Arg.Any()) - .Returns(0); - - var orchestratorCallCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - orchestratorCallCount++; - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(350); // Let it run a few cycles - - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Should have called orchestrator at least once - orchestratorCallCount.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task ExecuteAsync_GracefulShutdown_CompletesCleanly() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromSeconds(10) // Long interval - }); - - var repository = Substitute.For(); - var orchestrator = Substitute.For(); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - - // Request cancellation after brief delay - await Task.Delay(50); - cts.Cancel(); - - // Should not throw and should complete - await service.StopAsync(CancellationToken.None); - - // Assert: No exceptions thrown during shutdown - } - - #endregion - - #region CloseOpenUpdateEntries at Startup - - [Fact] - public async Task ExecuteAsync_AtStartup_CallsCloseOpenUpdateEntries() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var closeEntriesCallCount = 0; - var repository = Substitute.For(); - repository.CloseOpenUpdateEntriesAsync(Arg.Any()) - .Returns(x => - { - closeEntriesCallCount++; - return Task.FromResult(0); - }); - - var orchestrator = Substitute.For(); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(100); - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert - closeEntriesCallCount.ShouldBe(1); - } - - [Fact] - public async Task ExecuteAsync_WhenCloseOpenEntriesFindsEntries_LogsAndContinues() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - repository.CloseOpenUpdateEntriesAsync(Arg.Any()) - .Returns(5); // Found 5 interrupted entries - - var orchestratorCallCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - orchestratorCallCount++; - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(150); - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Should have continued to orchestrator after close - orchestratorCallCount.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - repository.CloseOpenUpdateEntriesAsync(Arg.Any()) - .Returns(x => throw new Exception("Database error")); - - var orchestratorCallCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - orchestratorCallCount++; - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - Should not throw even if CloseOpenUpdateEntries fails - await service.StartAsync(cts.Token); - await Task.Delay(150); - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Should have continued and called orchestrator - orchestratorCallCount.ShouldBeGreaterThan(0); - } - - #endregion - - #region Parallel Sync Execution - - [Fact] - public async Task ExecuteAsync_CallsOrchestratorForParallelExecution() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50), - MaxDegreeOfParallelism = 4 - }); - - var repository = Substitute.For(); - - var orchestratorCallCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - orchestratorCallCount++; - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(200); // Let multiple cycles run - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Orchestrator should be called to handle parallel execution - orchestratorCallCount.ShouldBeGreaterThan(0); - } - - [Fact] - public async Task ExecuteAsync_WhenOrchestratorThrows_ContinuesNextCycle() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - - var callCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - callCount++; - if (callCount == 1) - { - throw new Exception("Sync error"); - } - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(250); // Let multiple cycles run - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Should have been called multiple times despite first failure - callCount.ShouldBeGreaterThan(1); - } - - #endregion - - #region Cancellation Handling - - [Fact] - public async Task ExecuteAsync_WhenCancelled_StopsGracefully() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromSeconds(10) - }); - - var repository = Substitute.For(); - var orchestrator = Substitute.For(); - - // Make orchestrator take some time but respect cancellation - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(async x => - { - try - { - await Task.Delay(5000, x.Arg()); - } - catch (OperationCanceledException) - { - // Expected - swallow and return - } - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(100); - - // Cancel while orchestrator is running - cts.Cancel(); - - // Should complete without hanging - var stopTask = service.StopAsync(CancellationToken.None); - var completed = await Task.WhenAny(stopTask, Task.Delay(2000)); - - // Assert: Should complete, not hang - completed.ShouldBe(stopTask); - } - - [Fact] - public async Task ExecuteAsync_PassesCancellationTokenToOrchestrator() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - var orchestrator = Substitute.For(); - - var tokenWasProvided = false; - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - var token = x.Arg(); - tokenWasProvided = token != default; - return Task.CompletedTask; - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(100); - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Token should have been passed - tokenWasProvided.ShouldBeTrue(); - } - - [Fact] - public async Task ExecuteAsync_WhenCancelledDuringDelay_ExitsCleanly() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMinutes(5) // Long delay - }); - - var repository = Substitute.For(); - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(Task.CompletedTask); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - - // Service should be in delay after first cycle - await Task.Delay(100); - - // Cancel during delay - cts.Cancel(); - - // Should exit quickly - var stopTask = service.StopAsync(CancellationToken.None); - var completed = await Task.WhenAny(stopTask, Task.Delay(1000)); - - // Assert - completed.ShouldBe(stopTask); - } - - #endregion - - #region Service Scope Isolation - - [Fact] - public async Task ExecuteAsync_UsesNewScopePerCycle() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - var orchestrator = Substitute.For(); - - var scopeCount = 0; - var services = new ServiceCollection(); - services.AddScoped(sp => - { - Interlocked.Increment(ref scopeCount); - return repository; - }); - services.AddScoped(sp => orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(200); // Multiple cycles - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Multiple scopes should have been created - scopeCount.ShouldBeGreaterThan(1); - } - - #endregion - - #region Error Handling and Metrics - - [Fact] - public async Task ExecuteAsync_WhenSyncFails_ContinuesRunning() - { - // Arrange - var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions - { - Enabled = true, - CheckInterval = TimeSpan.FromMilliseconds(50) - }); - - var repository = Substitute.For(); - - var callCount = 0; - var orchestrator = Substitute.For(); - orchestrator.ExecutePendingSyncsAsync(Arg.Any()) - .Returns(x => - { - callCount++; - throw new Exception("Sync failed"); - }); - - var services = new ServiceCollection(); - services.AddSingleton(repository); - services.AddSingleton(orchestrator); - var serviceProvider = services.BuildServiceProvider(); - var scopeFactory = serviceProvider.GetRequiredService(); - - var metrics = CreateMetrics(); - - var service = new DataSyncService( - scopeFactory, - options, - NullLogger.Instance, - metrics); - - using var cts = new CancellationTokenSource(); - - // Act - await service.StartAsync(cts.Token); - await Task.Delay(200); - cts.Cancel(); - await service.StopAsync(CancellationToken.None); - - // Assert: Should have continued calling orchestrator despite failures - callCount.ShouldBeGreaterThan(1); - } - - #endregion - - #region Helper Methods - - private static DataSyncMetrics CreateMetrics() - { - // Use real MeterFactory since mocking Meter is complex - var services = new ServiceCollection(); - services.AddMetrics(); - var provider = services.BuildServiceProvider(); - var meterFactory = provider.GetRequiredService(); - return new DataSyncMetrics(meterFactory); - } - - #endregion -} diff --git a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs index 5d5c2f2..b27caf4 100644 --- a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs +++ b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs @@ -211,9 +211,9 @@ public class ScheduleCheckerTests } [Fact] - public async Task GetPendingTasksAsync_HourlySync_UsesDailyTimestampForMinimumDT() + public async Task GetPendingTasksAsync_HourlySync_UsesHourlyTimestampForMinimumDT() { - // Arrange: Per legacy behavior, hourly uses DAILY's timestamp for MinimumDT calculation + // Arrange: Hourly uses its own timestamp and interval for MinimumDT calculation var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 10080, dailyEnabled: true, dailyInterval: 1440, @@ -241,8 +241,8 @@ public class ScheduleCheckerTests 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); + // Hourly uses hourly's timestamp and hourly's interval for lookback calculation + var expectedMinimumDt = lastHourly.EndDt.AddMinutes(-3 * 60); tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1)); }