using System.Diagnostics.Metrics; using System.Linq.Expressions; using System.Runtime.CompilerServices; using JdeScoping.Core.Interfaces; using JdeScoping.Core.Models; using JdeScoping.Core.Models.Enums; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataSync.Options; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Models; using JdeScoping.DataSync.Services; using JdeScoping.DataSync.Telemetry; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; namespace JdeScoping.DataSync.Tests; /// /// Unit tests for TableSyncOperation. /// Tests mass/incremental paths, batching, and post-processor execution. /// public class TableSyncOperationTests { private readonly IDbConnectionFactory _connectionFactory; private readonly IDataUpdateRepository _updateRepository; private readonly IBulkMergeHelper _bulkMergeHelper; private readonly IMergeConfigurationRegistry _configRegistry; private readonly IOptions _options; private readonly DataSyncMetrics _metrics; private readonly IServiceProvider _serviceProvider; public TableSyncOperationTests() { _connectionFactory = Substitute.For(); _updateRepository = Substitute.For(); _bulkMergeHelper = Substitute.For(); _configRegistry = Substitute.For(); _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions { BatchSize = 1000, BulkCopyBatchSize = 100 }); var services = new ServiceCollection(); services.AddMetrics(); var provider = services.BuildServiceProvider(); var meterFactory = provider.GetRequiredService(); _metrics = new DataSyncMetrics(meterFactory); _serviceProvider = Substitute.For(); } #region Update Logging Tests [Fact] public async Task ExecuteAsync_StartsUpdateWithInProgressMarker() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(123); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(0, TimeSpan.Zero, true)); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert await _updateRepository.Received(1).StartUpdateAsync( task.SourceSystem, task.SourceData, task.TableName, task.UpdateType, Arg.Any()); } [Fact] public async Task ExecuteAsync_OnSuccess_CompletesUpdateWithRecordCount() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(123); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(500, TimeSpan.Zero, true)); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert await _updateRepository.Received(1).CompleteUpdateAsync( 123, 500L, true, Arg.Any()); } [Fact] public async Task ExecuteAsync_OnFailure_CompletesUpdateWithFailureMarker() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(123); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Database error")); var sut = CreateSut(); // Act & Assert await Should.ThrowAsync(() => sut.ExecuteAsync(task)); // Verify update was marked as failed await _updateRepository.Received(1).CompleteUpdateAsync( 123, -1, false, Arg.Any()); } [Fact] public async Task ExecuteAsync_OnCancellation_MarksUpdateAsFailed() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass); var cts = new CancellationTokenSource(); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(123); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(async callInfo => { cts.Cancel(); callInfo.Arg().ThrowIfCancellationRequested(); return new MassInsertResult(0, TimeSpan.Zero, true); }); var sut = CreateSut(); // Act & Assert await Should.ThrowAsync(() => sut.ExecuteAsync(task, cts.Token)); // Verify update was marked as failed await _updateRepository.Received(1).CompleteUpdateAsync( 123, -1, false, Arg.Any()); } #endregion #region Mass Update Path Tests [Fact] public async Task ExecuteAsync_MassWithPrepurge_UsesMassUpdatePath() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass, prepurge: true); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(1); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(100, TimeSpan.Zero, true)); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert: Should use mass insert path await _bulkMergeHelper.Received(1).MassInsertAsync( Arg.Any>(), "TestTable", task.ScheduleConfig.ReIndexData, _options.Value.BulkCopyBatchSize, Arg.Any()); // Should NOT use merge path await _bulkMergeHelper.DidNotReceive().MergeAsync( Arg.Any>(), Arg.Any(), Arg.Any>>(), Arg.Any>>(), Arg.Any>>(), Arg.Any>>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } #endregion #region Incremental Update Path Tests // Note: The following tests are marked as integration tests because they require // complex reflection-based fetcher resolution that's difficult to unit test. // These scenarios should be covered by integration tests with a real test database. // // Scenarios covered by integration tests: // - ExecuteAsync_DailyUpdate_UsesIncrementalPath // - ExecuteAsync_HourlyUpdate_UsesIncrementalPath // - ExecuteAsync_LargeDataset_ProcessesInBatches (incremental path) [Fact] public void IncrementalUpdatePath_RequiresIntegrationTest() { // This test documents that incremental update scenarios require integration testing // because the TableSyncOperation uses reflection to resolve and invoke fetchers. // // Integration test should verify: // 1. Daily updates use staging table → merge path // 2. Hourly updates use staging table → merge path // 3. Staging tables are created with unique suffixes // 4. MERGE correctly handles INSERT/UPDATE based on LastUpdateDT // 5. Staging tables are cleaned up after success and failure Assert.True(true, "See integration tests for incremental update path coverage"); } #endregion #region Batching Tests // Note: Batching tests for incremental updates require integration testing // because they depend on the reflection-based fetcher resolution. // The batching logic is tested implicitly through integration tests. // // Test scenario to verify: // - Large dataset (25 entities) with BatchSize=10 should create 3 batches #endregion #region Post-Processor Tests [Fact] public async Task ExecuteAsync_WithPostProcessor_InvokesPostProcessor() { // Arrange var task = CreateTask("MisData", UpdateTypes.Mass, postProcessor: nameof(MockPostProcessor)); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(1); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(0, TimeSpan.Zero, true)); var mockPostProcessor = new MockPostProcessor(); _serviceProvider.GetService(typeof(MockPostProcessor)).Returns(mockPostProcessor); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert mockPostProcessor.WasInvoked.ShouldBeTrue(); mockPostProcessor.TableNameReceived.ShouldBe("MisData"); } [Fact] public async Task ExecuteAsync_WithoutPostProcessor_SkipsPostProcessing() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass, postProcessor: null); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(1); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(0, TimeSpan.Zero, true)); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert: No post-processor service resolution should occur _serviceProvider.DidNotReceive().GetService(typeof(IPostProcessor)); } #endregion #region Metrics Tests [Fact] public async Task ExecuteAsync_RecordsOperationStartedMetric() { // Arrange var task = CreateTask("WorkOrder", UpdateTypes.Mass); _updateRepository.StartUpdateAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(1); SetupMockFetcher(task, AsyncEnumerable.Empty()); SetupMockMergeConfiguration(); _bulkMergeHelper.MassInsertAsync( Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new MassInsertResult(100, TimeSpan.Zero, true)); var sut = CreateSut(); // Act await sut.ExecuteAsync(task); // Assert: Metrics were recorded (using real metrics, just verify no exceptions) // Detailed metric verification is in DataSyncMetricsTests } #endregion #region Staging Table Cleanup Tests // Note: Staging table cleanup tests require integration testing because // they depend on the incremental update path with reflection-based fetcher resolution. // // Test scenarios to verify: // - On successful merge, staging table is dropped // - On merge failure, staging table is still dropped (finally block) // - On bulk copy failure, staging table is still dropped #endregion #region Helper Methods private TableSyncOperation CreateSut() { return new TableSyncOperation( _serviceProvider, _connectionFactory, _updateRepository, _bulkMergeHelper, _configRegistry, _options, NullLogger.Instance, _metrics); } private static DataUpdateTask CreateTask( string tableName, UpdateTypes updateType, bool prepurge = true, bool reindex = true, string? postProcessor = null) { return new DataUpdateTask { TableName = tableName, SourceSystem = "JDE", SourceData = tableName.ToUpper(), UpdateType = updateType, MinimumDt = updateType == UpdateTypes.Mass ? null : DateTime.UtcNow.AddDays(-1), Config = new DataSourceConfig { TableName = tableName, SourceSystem = "JDE", SourceData = tableName.ToUpper(), FetcherTypeName = nameof(MockDataFetcher), PostProcessorTypeName = postProcessor, IsEnabled = true, MassConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 10080, PrepurgeData = prepurge, ReIndexData = reindex }, DailyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 1440 }, HourlyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 60 } } }; } private void SetupMockFetcher(DataUpdateTask task, IAsyncEnumerable entities, int batchSize = 1000) { var fetcher = new MockDataFetcher(entities); _serviceProvider.GetService(typeof(MockDataFetcher)).Returns(fetcher); } private void SetupMockMergeConfiguration() { var mockConfig = Substitute.For>(); mockConfig.TableName.Returns("TestTable"); mockConfig.MatchOn.Returns(x => x.Id); mockConfig.UpdateColumns.Returns(x => new { x.Name, x.LastUpdateDT }); mockConfig.UpdateWhen.Returns((Expression>?)null); mockConfig.InsertColumns.Returns((Expression>?)null); _configRegistry.GetConfiguration().Returns(mockConfig); } #endregion } #region Test Support Classes public class TestEntity { public int Id { get; set; } public string Name { get; set; } = string.Empty; public DateTime LastUpdateDT { get; set; } = DateTime.UtcNow; } public class MockDataFetcher : IDataFetcher { private readonly IAsyncEnumerable _entities; public MockDataFetcher(IAsyncEnumerable? entities = null) { _entities = entities ?? AsyncEnumerable.Empty(); } public IAsyncEnumerable FetchAsync(DateTime? minimumDt, CancellationToken cancellationToken = default) { return _entities; } } public class MockPostProcessor : IPostProcessor { public bool WasInvoked { get; private set; } public string? TableNameReceived { get; private set; } public Task ProcessAsync(string tableName, CancellationToken cancellationToken = default) { WasInvoked = true; TableNameReceived = tableName; return Task.CompletedTask; } } #endregion