using System.Diagnostics.Metrics; using JdeScoping.DataSync.Configuration; 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 }