Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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<BulkMergeHelper> _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<IDbConnectionFactory>();
|
||||
_dataReaderFactory = Substitute.For<IDataReaderFactory>();
|
||||
_schemaValidator = Substitute.For<ISchemaValidator>();
|
||||
_logger = Substitute.For<ILogger<BulkMergeHelper>>();
|
||||
|
||||
// Setup default mock returns
|
||||
_dataReaderFactory.GetColumnNames<TestEntity>()
|
||||
.Returns(new List<string> { "Id", "Name", "Amount" });
|
||||
|
||||
_helper = new BulkMergeHelper(
|
||||
_connectionFactory,
|
||||
_dataReaderFactory,
|
||||
_schemaValidator,
|
||||
_logger);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullConnectionFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new BulkMergeHelper(null!, _dataReaderFactory, _schemaValidator, _logger));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullDataReaderFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new BulkMergeHelper(_connectionFactory, null!, _schemaValidator, _logger));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSchemaValidator_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new BulkMergeHelper(_connectionFactory, _dataReaderFactory, null!, _logger));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new BulkMergeHelper(_connectionFactory, _dataReaderFactory, _schemaValidator, null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MergeAsync Parameter Validation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_NullData_ThrowsArgumentNullException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_helper.MergeAsync<TestEntity>(
|
||||
null!,
|
||||
"TestTable",
|
||||
x => x.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_NullDestinationTable_ThrowsArgumentNullException()
|
||||
{
|
||||
var data = AsyncEnumerable.Empty<TestEntity>();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_helper.MergeAsync(
|
||||
data,
|
||||
null!,
|
||||
x => x.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_EmptyDestinationTable_ThrowsArgumentException()
|
||||
{
|
||||
var data = AsyncEnumerable.Empty<TestEntity>();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_helper.MergeAsync(
|
||||
data,
|
||||
"",
|
||||
x => x.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_NullMatchOn_ThrowsArgumentNullException()
|
||||
{
|
||||
var data = AsyncEnumerable.Empty<TestEntity>();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_helper.MergeAsync<TestEntity>(
|
||||
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<TestEntity>(x => x.Id);
|
||||
|
||||
Assert.Single(columns);
|
||||
Assert.Equal("Id", columns[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_CalledWithMultipleColumns_ReturnsCorrectColumns()
|
||||
{
|
||||
var columns = ExpressionParser.GetColumnNames<TestEntity>(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<ArgumentNullException>(
|
||||
() => _helper.MassInsertAsync<TestEntity>(null!, "TestTable"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MassInsertAsync_NullDestination_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var data = AsyncEnumerable.Empty<TestEntity>();
|
||||
|
||||
// Act & Assert
|
||||
// ArgumentException.ThrowIfNullOrWhiteSpace throws ArgumentNullException for null values
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _helper.MassInsertAsync(data, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MassInsertAsync_EmptyDestination_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var data = AsyncEnumerable.Empty<TestEntity>();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _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<ValidationError>
|
||||
{
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Linq.Expressions;
|
||||
using JdeScoping.DataSync.Services;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class ExpressionParserTests
|
||||
{
|
||||
private class TestEntity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime? LastUpdateDt { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
#region GetColumnNames Tests
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_SingleProperty_ReturnsSingleColumn()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, object>> expr = x => x.Id;
|
||||
|
||||
// Act
|
||||
var columns = ExpressionParser.GetColumnNames(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Single(columns);
|
||||
Assert.Equal("Id", columns[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_SingleStringProperty_ReturnsSingleColumn()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, object>> expr = x => x.Name;
|
||||
|
||||
// Act
|
||||
var columns = ExpressionParser.GetColumnNames(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Single(columns);
|
||||
Assert.Equal("Name", columns[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_AnonymousType_ReturnsAllColumns()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, object>> expr = x => new { x.Id, x.Name };
|
||||
|
||||
// Act
|
||||
var columns = ExpressionParser.GetColumnNames(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, columns.Count);
|
||||
Assert.Equal("Id", columns[0]);
|
||||
Assert.Equal("Name", columns[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_AnonymousTypeWithMultipleProperties_ReturnsAllColumns()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, object>> expr = x => new { x.Id, x.Name, x.Amount, x.LastUpdateDt };
|
||||
|
||||
// Act
|
||||
var columns = ExpressionParser.GetColumnNames(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, columns.Count);
|
||||
Assert.Equal("Id", columns[0]);
|
||||
Assert.Equal("Name", columns[1]);
|
||||
Assert.Equal("Amount", columns[2]);
|
||||
Assert.Equal("LastUpdateDt", columns[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetColumnNames_NullExpression_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
ExpressionParser.GetColumnNames<TestEntity>(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildUpdateWhenSql Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_NullExpression_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql<TestEntity>(null);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_GreaterThan_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("source.[LastUpdateDt] > target.[LastUpdateDt]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_GreaterThanOrEqual_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Id >= tgt.Id;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("source.[Id] >= target.[Id]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_Equal_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Name == tgt.Name;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("source.[Name] = target.[Name]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_NotEqual_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Amount != tgt.Amount;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("source.[Amount] <> target.[Amount]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_AndCondition_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr =
|
||||
(src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt && src.Id == tgt.Id;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("(source.[LastUpdateDt] > target.[LastUpdateDt] AND source.[Id] = target.[Id])", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_OrCondition_ReturnsSqlCondition()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr =
|
||||
(src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt || src.Amount > tgt.Amount;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("(source.[LastUpdateDt] > target.[LastUpdateDt] OR source.[Amount] > target.[Amount])", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateWhenSql_CustomAliases_UsesProvidedAliases()
|
||||
{
|
||||
// Arrange
|
||||
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Id > tgt.Id;
|
||||
|
||||
// Act
|
||||
var result = ExpressionParser.BuildUpdateWhenSql(expr, "s", "t");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("s.[Id] > t.[Id]", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using JdeScoping.Core.Models.WorkOrders;
|
||||
using JdeScoping.DataSync.Configuration.MergeConfigurations;
|
||||
using JdeScoping.DataSync.Contracts;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class MergeConfigurationRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetConfiguration_RegisteredType_ReturnsConfiguration()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IMergeConfiguration<WorkOrder>, WorkOrderMergeConfiguration>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var registry = new MergeConfigurationRegistry(provider);
|
||||
|
||||
// Act
|
||||
var config = registry.GetConfiguration<WorkOrder>();
|
||||
|
||||
// Assert
|
||||
config.ShouldNotBeNull();
|
||||
config.TableName.ShouldBe("WorkOrder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConfiguration_UnregisteredType_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var registry = new MergeConfigurationRegistry(provider);
|
||||
|
||||
// Act & Assert
|
||||
var ex = Should.Throw<InvalidOperationException>(() => registry.GetConfiguration<UnregisteredEntity>());
|
||||
ex.Message.ShouldContain("UnregisteredEntity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasConfiguration_RegisteredType_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IMergeConfiguration<WorkOrder>, WorkOrderMergeConfiguration>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var registry = new MergeConfigurationRegistry(provider);
|
||||
|
||||
// Act
|
||||
var result = registry.HasConfiguration<WorkOrder>();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasConfiguration_UnregisteredType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var registry = new MergeConfigurationRegistry(provider);
|
||||
|
||||
// Act
|
||||
var result = registry.HasConfiguration<UnregisteredEntity>();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private class UnregisteredEntity { }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using JdeScoping.DataSync.Services;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class MergeSqlBuilderTests
|
||||
{
|
||||
#region BuildCreateTempTable Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildCreateTempTable_ValidInputs_ReturnsSelectInto()
|
||||
{
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildCreateTempTable("#TEMP_WorkOrder", "WorkOrder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("SELECT TOP 0 * INTO [#TEMP_WorkOrder] FROM [WorkOrder]", sql);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "WorkOrder")]
|
||||
[InlineData("", "WorkOrder")]
|
||||
[InlineData("#TEMP", null)]
|
||||
[InlineData("#TEMP", "")]
|
||||
public void BuildCreateTempTable_InvalidInputs_ThrowsArgumentException(string? tempTable, string? sourceTable)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<ArgumentException>(() =>
|
||||
MergeSqlBuilder.BuildCreateTempTable(tempTable!, sourceTable!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildMergeSimple Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_SingleMatchColumn_BuildsCorrectMerge()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = new[] { "Id" };
|
||||
var updateColumns = new[] { "Name", "Amount" };
|
||||
var insertColumns = new[] { "Id", "Name", "Amount" };
|
||||
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildMergeSimple(
|
||||
"TestTable", "#TEMP_TestTable",
|
||||
matchColumns, updateColumns, null, insertColumns);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("MERGE INTO [TestTable] AS target", sql);
|
||||
Assert.Contains("USING [#TEMP_TestTable] AS source", sql);
|
||||
Assert.Contains("ON target.[Id] = source.[Id]", sql);
|
||||
Assert.Contains("WHEN MATCHED THEN", sql);
|
||||
Assert.Contains("UPDATE SET target.[Name] = source.[Name], target.[Amount] = source.[Amount]", sql);
|
||||
Assert.Contains("WHEN NOT MATCHED THEN", sql);
|
||||
Assert.Contains("INSERT ([Id], [Name], [Amount])", sql);
|
||||
Assert.Contains("VALUES (source.[Id], source.[Name], source.[Amount])", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_CompositeKey_BuildsCorrectOnClause()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = new[] { "WorkOrderNumber", "BranchCode" };
|
||||
var updateColumns = new[] { "Status" };
|
||||
var insertColumns = new[] { "WorkOrderNumber", "BranchCode", "Status" };
|
||||
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildMergeSimple(
|
||||
"WorkOrder", "#TEMP",
|
||||
matchColumns, updateColumns, null, insertColumns);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("ON target.[WorkOrderNumber] = source.[WorkOrderNumber] AND target.[BranchCode] = source.[BranchCode]", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_WithUpdateWhen_IncludesCondition()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = new[] { "Id" };
|
||||
var updateColumns = new[] { "Name" };
|
||||
var insertColumns = new[] { "Id", "Name" };
|
||||
var updateWhen = "source.[LastUpdateDt] > target.[LastUpdateDt]";
|
||||
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildMergeSimple(
|
||||
"TestTable", "#TEMP",
|
||||
matchColumns, updateColumns, updateWhen, insertColumns);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("WHEN MATCHED AND source.[LastUpdateDt] > target.[LastUpdateDt] THEN", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_NoUpdateColumns_OmitsUpdateClause()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = new[] { "Id" };
|
||||
var updateColumns = Array.Empty<string>();
|
||||
var insertColumns = new[] { "Id", "Name" };
|
||||
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildMergeSimple(
|
||||
"TestTable", "#TEMP",
|
||||
matchColumns, updateColumns, null, insertColumns);
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("WHEN MATCHED", sql);
|
||||
Assert.Contains("WHEN NOT MATCHED THEN", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_EmptyMatchColumns_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = Array.Empty<string>();
|
||||
var updateColumns = new[] { "Name" };
|
||||
var insertColumns = new[] { "Id", "Name" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MergeSqlBuilder.BuildMergeSimple(
|
||||
"TestTable", "#TEMP",
|
||||
matchColumns, updateColumns, null, insertColumns));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMergeSimple_EmptyInsertColumns_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var matchColumns = new[] { "Id" };
|
||||
var updateColumns = new[] { "Name" };
|
||||
var insertColumns = Array.Empty<string>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
MergeSqlBuilder.BuildMergeSimple(
|
||||
"TestTable", "#TEMP",
|
||||
matchColumns, updateColumns, null, insertColumns));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildTruncateTempTable Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildTruncateTempTable_ValidInput_ReturnsTruncate()
|
||||
{
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildTruncateTempTable("#TEMP_WorkOrder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TRUNCATE TABLE [#TEMP_WorkOrder]", sql);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildDropTempTable Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildDropTempTable_ValidInput_ReturnsDropWithCheck()
|
||||
{
|
||||
// Act
|
||||
var sql = MergeSqlBuilder.BuildDropTempTable("#TEMP_WorkOrder");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("IF OBJECT_ID('tempdb..#TEMP_WorkOrder') IS NOT NULL", sql);
|
||||
Assert.Contains("DROP TABLE [#TEMP_WorkOrder]", sql);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using JdeScoping.DataSync.Models;
|
||||
using JdeScoping.DataSync.Services;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class SchemaValidatorTests
|
||||
{
|
||||
private readonly SchemaValidator _validator = new();
|
||||
|
||||
private class TestEntity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? NullableName { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
}
|
||||
|
||||
#region ValidateBatch Tests
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_EmptyData_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var data = Array.Empty<TestEntity>();
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_EmptySchema_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity> { new() { Id = 1, Name = "Test" } };
|
||||
var schema = Array.Empty<ColumnSchema>();
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_ValidData_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "Test", Amount = 100.50m }
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2),
|
||||
new("Amount", "decimal", null, 10, 2, false, 3)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_StringTooLong_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "This is a very long string that exceeds the maximum length" }
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 10, null, null, false, 2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Single(errors);
|
||||
Assert.Equal("Name", errors[0].ColumnName);
|
||||
Assert.Equal(0, errors[0].RowIndex);
|
||||
Assert.Contains("exceeds maximum length", errors[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_NullInNonNullableColumn_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "" } // Empty string treated as null for non-nullable
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Single(errors);
|
||||
Assert.Equal("Name", errors[0].ColumnName);
|
||||
Assert.Contains("does not allow null", errors[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_NullInNullableColumn_NoError()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "Test", NullableName = null }
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2),
|
||||
new("NullableName", "nvarchar", 50, null, null, true, 3)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_DecimalOverflow_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "Test", Amount = 12345678.90m } // Too many integer digits for decimal(8,2)
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2),
|
||||
new("Amount", "decimal", null, 8, 2, false, 3) // Max 6 integer digits
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Single(errors);
|
||||
Assert.Equal("Amount", errors[0].ColumnName);
|
||||
Assert.Contains("exceeds maximum integer digits", errors[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_DecimalWithinRange_NoError()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "Test", Amount = 123456.78m } // Within decimal(10,2) - 8 integer digits
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2),
|
||||
new("Amount", "decimal", null, 10, 2, false, 3) // Max 8 integer digits
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_MultipleErrors_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "This is too long" },
|
||||
new() { Id = 2, Name = "Also too long!" }
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 5, null, null, false, 2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, errors.Count);
|
||||
Assert.Equal(0, errors[0].RowIndex);
|
||||
Assert.Equal(1, errors[1].RowIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_MaxErrors_StopsAtLimit()
|
||||
{
|
||||
// Arrange
|
||||
var data = Enumerable.Range(0, 10)
|
||||
.Select(i => new TestEntity { Id = i, Name = "This is way too long" })
|
||||
.ToList();
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 5, null, null, false, 2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema, maxErrors: 3);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, errors.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_UnmatchedColumn_Ignored()
|
||||
{
|
||||
// Arrange
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 1, Name = "Test" }
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1),
|
||||
new("Name", "nvarchar", 50, null, null, false, 2),
|
||||
new("UnknownColumn", "nvarchar", 50, null, null, false, 3)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBatch_IdColumn_AllowsNull()
|
||||
{
|
||||
// Arrange - Id columns are treated as identity/auto-generated
|
||||
var data = new List<TestEntity>
|
||||
{
|
||||
new() { Id = 0, Name = "Test" } // Id = 0 might be treated as "not set"
|
||||
};
|
||||
var schema = new List<ColumnSchema>
|
||||
{
|
||||
new("Id", "int", null, 10, 0, false, 1), // Not nullable in schema
|
||||
new("Name", "nvarchar", 50, null, null, false, 2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = _validator.ValidateBatch(data, schema);
|
||||
|
||||
// Assert - No error because Id columns are treated specially
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user