using System.Data; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Exceptions; using JdeScoping.DataSync.Models; using JdeScoping.DataSync.Services; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using NSubstitute; namespace JdeScoping.DataSync.Tests.Services; public class BulkMergeHelperTests { private readonly IDbConnectionFactory _connectionFactory; private readonly IDataReaderFactory _dataReaderFactory; private readonly ISchemaValidator _schemaValidator; private readonly ILogger _logger; private readonly BulkMergeHelper _helper; private class TestEntity { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Amount { get; set; } } public BulkMergeHelperTests() { _connectionFactory = Substitute.For(); _dataReaderFactory = Substitute.For(); _schemaValidator = Substitute.For(); _logger = Substitute.For>(); // Setup default mock returns _dataReaderFactory.GetColumnNames() .Returns(new List { "Id", "Name", "Amount" }); _helper = new BulkMergeHelper( _connectionFactory, _dataReaderFactory, _schemaValidator, _logger); } #region Constructor Tests [Fact] public void Constructor_NullConnectionFactory_ThrowsArgumentNullException() { Assert.Throws(() => new BulkMergeHelper(null!, _dataReaderFactory, _schemaValidator, _logger)); } [Fact] public void Constructor_NullDataReaderFactory_ThrowsArgumentNullException() { Assert.Throws(() => new BulkMergeHelper(_connectionFactory, null!, _schemaValidator, _logger)); } [Fact] public void Constructor_NullSchemaValidator_ThrowsArgumentNullException() { Assert.Throws(() => new BulkMergeHelper(_connectionFactory, _dataReaderFactory, null!, _logger)); } [Fact] public void Constructor_NullLogger_ThrowsArgumentNullException() { Assert.Throws(() => new BulkMergeHelper(_connectionFactory, _dataReaderFactory, _schemaValidator, null!)); } #endregion #region MergeAsync Parameter Validation Tests [Fact] public async Task MergeAsync_NullData_ThrowsArgumentNullException() { await Assert.ThrowsAsync(() => _helper.MergeAsync( null!, "TestTable", x => x.Id)); } [Fact] public async Task MergeAsync_NullDestinationTable_ThrowsArgumentNullException() { var data = AsyncEnumerable.Empty(); await Assert.ThrowsAsync(() => _helper.MergeAsync( data, null!, x => x.Id)); } [Fact] public async Task MergeAsync_EmptyDestinationTable_ThrowsArgumentException() { var data = AsyncEnumerable.Empty(); await Assert.ThrowsAsync(() => _helper.MergeAsync( data, "", x => x.Id)); } [Fact] public async Task MergeAsync_NullMatchOn_ThrowsArgumentNullException() { var data = AsyncEnumerable.Empty(); await Assert.ThrowsAsync(() => _helper.MergeAsync( data, "TestTable", null!)); } #endregion #region Column Expression Tests [Fact] public void GetColumnNames_CalledWithMatchOn_ReturnsCorrectColumns() { // The ExpressionParser is tested separately, this just verifies it's being called var columns = ExpressionParser.GetColumnNames(x => x.Id); Assert.Single(columns); Assert.Equal("Id", columns[0]); } [Fact] public void GetColumnNames_CalledWithMultipleColumns_ReturnsCorrectColumns() { var columns = ExpressionParser.GetColumnNames(x => new { x.Id, x.Name }); Assert.Equal(2, columns.Count); Assert.Equal("Id", columns[0]); Assert.Equal("Name", columns[1]); } #endregion #region TempTableName Generation Tests [Fact] public void TempTableName_WithDots_ReplacesWithUnderscores() { // This is implicitly tested by how temp table names are generated var tableName = "dbo.TestTable"; // The actual generation happens inside MergeAsync, so we verify the pattern var cleaned = tableName.Replace(".", "_").Replace("[", "").Replace("]", ""); Assert.Equal("dbo_TestTable", cleaned); } [Fact] public void TempTableName_WithBrackets_RemovesBrackets() { var tableName = "[dbo].[TestTable]"; var cleaned = tableName.Replace(".", "_").Replace("[", "").Replace("]", ""); Assert.Equal("dbo_TestTable", cleaned); } #endregion #region MergeResult Tests [Fact] public void MergeResult_TotalRowsAffected_ReturnsSum() { var result = new MergeResult(100, 60, 40, 10, TimeSpan.FromSeconds(5)); Assert.Equal(100, result.TotalRowsAffected); } [Fact] public void MergeResult_RecordProperties_AreCorrect() { var elapsed = TimeSpan.FromSeconds(5); var result = new MergeResult(100, 60, 40, 10, elapsed); Assert.Equal(100, result.TotalRowsProcessed); Assert.Equal(60, result.RowsInserted); Assert.Equal(40, result.RowsUpdated); Assert.Equal(10, result.BatchCount); Assert.Equal(elapsed, result.Elapsed); } #endregion #region MassInsertAsync Tests [Fact] public async Task MassInsertAsync_NullData_ThrowsArgumentNullException() { // Act & Assert await Assert.ThrowsAsync( () => _helper.MassInsertAsync(null!, "TestTable")); } [Fact] public async Task MassInsertAsync_NullDestination_ThrowsArgumentNullException() { // Arrange var data = AsyncEnumerable.Empty(); // Act & Assert // ArgumentException.ThrowIfNullOrWhiteSpace throws ArgumentNullException for null values await Assert.ThrowsAsync( () => _helper.MassInsertAsync(data, null!)); } [Fact] public async Task MassInsertAsync_EmptyDestination_ThrowsArgumentException() { // Arrange var data = AsyncEnumerable.Empty(); // Act & Assert await Assert.ThrowsAsync( () => _helper.MassInsertAsync(data, "")); } #endregion #region Exception Tests [Fact] public void BulkMergeException_PropertiesAreSet() { var ex = new BulkMergeException("Test error") { TableName = "TestTable", BatchNumber = 5, RowsInBatch = 1000, SqlStatement = "MERGE INTO..." }; Assert.Equal("TestTable", ex.TableName); Assert.Equal(5, ex.BatchNumber); Assert.Equal(1000, ex.RowsInBatch); Assert.Equal("MERGE INTO...", ex.SqlStatement); } [Fact] public void BulkMergeValidationException_ContainsErrors() { var errors = new List { new(0, "Name", "TooLong", "Exceeds max length"), new(1, "Amount", 999999m, "Overflow") }; var ex = new BulkMergeValidationException("Validation failed", errors); Assert.Equal(2, ex.Errors.Count); Assert.Equal("Name", ex.Errors[0].ColumnName); Assert.Equal("Amount", ex.Errors[1].ColumnName); } #endregion }