Files
jdescopingtool/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs
T
Joseph Doherty ec4c8fab87 refactor: relocate options classes to dedicated Options folders
Move configuration options from Core/DataAccess/DataSync/ExcelIO to
dedicated Options folders within each project for better organization.
Update all references and tests accordingly.
2026-01-03 08:55:08 -05:00

672 lines
22 KiB
C#

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;
/// <summary>
/// Integration tests for DataSyncService.
/// These tests verify the service lifecycle and orchestration behavior.
/// </summary>
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<IDataUpdateRepository>());
services.AddSingleton(Substitute.For<ISyncOrchestrator>());
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(0);
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
closeEntriesCallCount++;
return Task.FromResult(0);
});
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(5); // Found 5 interrupted entries
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns<int>(x => throw new Exception("Database error"));
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.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<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
// Make orchestrator take some time but respect cancellation
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(async x =>
{
try
{
await Task.Delay(5000, x.Arg<CancellationToken>());
}
catch (OperationCanceledException)
{
// Expected - swallow and return
}
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var tokenWasProvided = false;
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
var token = x.Arg<CancellationToken>();
tokenWasProvided = token != default;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var scopeCount = 0;
var services = new ServiceCollection();
services.AddScoped<IDataUpdateRepository>(sp =>
{
Interlocked.Increment(ref scopeCount);
return repository;
});
services.AddScoped<ISyncOrchestrator>(sp => orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.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<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.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<IMeterFactory>();
return new DataSyncMetrics(meterFactory);
}
#endregion
}