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:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -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
}