using System.Data; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataSync.Etl.Destinations; using NSubstitute; namespace JdeScoping.DataSync.Tests.Etl.Destinations; public class DbBulkMergeDestinationTests { /// /// This test documents that column mapping is applied to ignore extra source columns. /// The actual functionality requires a database connection and is an integration test concept. /// The implementation fetches destination columns from INFORMATION_SCHEMA.COLUMNS /// and only maps columns that exist in both source and destination. /// [Fact] public void WriteAsync_SourceHasExtraColumns_IgnoresExtraColumns_IntegrationTestConcept() { // This is an integration test concept - // The actual behavior verifies that column mappings are applied: // 1. GetDestinationColumnsAsync fetches columns from INFORMATION_SCHEMA.COLUMNS // 2. ProcessBatchAsync only adds column mappings for columns in destination // 3. Extra source columns are silently ignored during bulk copy // // To test this fully, an integration test with a real database is required. // The unit test here just verifies the component can be constructed. var factory = Substitute.For(); var dest = new DbBulkMergeDestination(factory, "TestTable", new[] { "Id" }); Assert.NotNull(dest); } [Fact] public void Constructor_SetsDestinationName() { var factory = Substitute.For(); var dest = new DbBulkMergeDestination(factory, "WorkOrder", new[] { "OrderNumber" }); Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); } [Fact] public void Constructor_NullFactory_ThrowsArgumentNullException() { Assert.Throws(() => new DbBulkMergeDestination(null!, "WorkOrder", new[] { "Id" })); } [Fact] public void Constructor_NullTableName_ThrowsArgumentNullException() { var factory = Substitute.For(); Assert.Throws(() => new DbBulkMergeDestination(factory, null!, new[] { "Id" })); } [Fact] public void Constructor_EmptyTableName_ThrowsArgumentException() { var factory = Substitute.For(); Assert.Throws(() => new DbBulkMergeDestination(factory, "", new[] { "Id" })); } [Fact] public void Constructor_EmptyMatchColumns_ThrowsArgumentException() { var factory = Substitute.For(); Assert.Throws(() => new DbBulkMergeDestination(factory, "WorkOrder", Array.Empty())); } [Fact] public void Constructor_NullMatchColumns_ThrowsArgumentNullException() { var factory = Substitute.For(); Assert.Throws(() => new DbBulkMergeDestination(factory, "WorkOrder", null!)); } [Fact] public void Constructor_WithUpdateColumns_Succeeds() { var factory = Substitute.For(); var dest = new DbBulkMergeDestination(factory, "WorkOrder", new[] { "OrderNumber" }, updateColumns: new[] { "Status", "Description" }); Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); } [Fact] public void Constructor_CustomTimeout_SetsTimeout() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "TestTable", new[] { "Id" }, commandTimeoutSeconds: 1800); // Assert - can't directly test private field, but constructor should accept it Assert.NotNull(dest); } [Fact] public void Constructor_ZeroTimeout_UsesDefault() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "TestTable", new[] { "Id" }, commandTimeoutSeconds: 0); // Assert Assert.NotNull(dest); } [Fact] public void Constructor_WithExcludeFromUpdate_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, excludeFromUpdate: new[] { "CreatedDate", "CreatedBy" }); // Assert Assert.NotNull(dest); Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); } [Fact] public void Constructor_WithUpdateCondition_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateCondition: "source.LastUpdate > target.LastUpdate"); // Assert Assert.NotNull(dest); Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); } [Fact] public void Constructor_WithAllNewParameters_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateColumns: new[] { "Status", "Description" }, excludeFromUpdate: new[] { "CreatedDate" }, updateCondition: "source.LastUpdate > target.LastUpdate", batchSize: 5000, commandTimeoutSeconds: 300); // Assert Assert.NotNull(dest); Assert.Equal("BulkMerge:WorkOrder", dest.DestinationName); } [Fact] public void Constructor_WithNullExcludeFromUpdate_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateColumns: null, excludeFromUpdate: null, updateCondition: null); // Assert Assert.NotNull(dest); } [Fact] public void Constructor_WithEmptyExcludeFromUpdate_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, excludeFromUpdate: Array.Empty()); // Assert Assert.NotNull(dest); } [Fact] public void Constructor_WithEmptyUpdateCondition_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateCondition: ""); // Assert Assert.NotNull(dest); } [Fact] public void Constructor_WithWhitespaceUpdateCondition_Succeeds() { // Arrange & Act var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateCondition: " "); // Assert Assert.NotNull(dest); } /// /// Documents that excludeFromUpdate takes precedence over updateColumns. /// If a column is in both updateColumns and excludeFromUpdate, it should be excluded. /// This is verified during actual MERGE SQL generation (integration test concept). /// [Fact] public void Constructor_WithConflictingUpdateAndExclude_Succeeds() { // Arrange & Act - excludeFromUpdate takes precedence over updateColumns var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateColumns: new[] { "Status", "CreatedDate" }, excludeFromUpdate: new[] { "CreatedDate" }); // Assert - construction succeeds; actual exclusion behavior is in MERGE SQL generation Assert.NotNull(dest); } /// /// Documents that updateCondition is applied to the WHEN MATCHED clause. /// This adds conditional update logic like "source.LastUpdate > target.LastUpdate" /// to prevent stale data from overwriting newer data. /// [Fact] public void UpdateCondition_DocumentsUsage_IntegrationTestConcept() { // The updateCondition parameter adds a condition to the MERGE statement's WHEN MATCHED clause. // Example: WHEN MATCHED AND source.LastUpdate > target.LastUpdate THEN UPDATE SET ... // // This is useful for: // - Preventing stale data from overwriting newer data // - Only updating rows that have actually changed // - Implementing optimistic concurrency patterns // // Full verification requires an integration test with a real database. var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, updateCondition: "source.LastUpdate > target.LastUpdate"); Assert.NotNull(dest); } /// /// Documents that excludeFromUpdate removes columns from the UPDATE SET clause. /// This is useful for columns that should only be set on INSERT (e.g., CreatedDate). /// [Fact] public void ExcludeFromUpdate_DocumentsUsage_IntegrationTestConcept() { // The excludeFromUpdate parameter removes specified columns from the UPDATE SET clause. // These columns are still included in the INSERT clause for new rows. // // Common use cases: // - CreatedDate, CreatedBy - set only on insert, never updated // - Audit columns that should preserve original values // // Full verification requires an integration test with a real database. var factory = Substitute.For(); var dest = new DbBulkMergeDestination( factory, "WorkOrder", new[] { "OrderNumber" }, excludeFromUpdate: new[] { "CreatedDate", "CreatedBy" }); Assert.NotNull(dest); } }