using JdeScoping.Core.Models.Enums; using JdeScoping.DataAccess.Interfaces; using JdeScoping.DataSync.Configuration; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Etl.Pipeline; using JdeScoping.DataSync.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Shouldly; namespace JdeScoping.DataSync.Tests.Services; public class EtlPipelineFactoryTests { private readonly IDbConnectionFactory _connectionFactory; private readonly ILogger _logger; public EtlPipelineFactoryTests() { _connectionFactory = Substitute.For(); _logger = NullLogger.Instance; } #region ForTable Tests [Fact] public void ForTable_WithValidTable_ReturnsBuilder() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var builder = factory.ForTable("TestTable"); // Assert builder.ShouldNotBeNull(); builder.ShouldBeAssignableTo(); } [Fact] public void ForTable_WithUnknownTable_ThrowsInvalidOperationException() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act & Assert var ex = Should.Throw(() => factory.ForTable("NonExistentTable")); ex.Message.ShouldContain("No pipeline configured for table: NonExistentTable"); ex.Message.ShouldContain("TestTable"); // Should list available tables } [Fact] public void ForTable_WithNullTableName_ThrowsArgumentException() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act & Assert Should.Throw(() => factory.ForTable(null!)); } [Fact] public void ForTable_WithEmptyTableName_ThrowsArgumentException() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act & Assert Should.Throw(() => factory.ForTable("")); } #endregion #region Builder WithUpdateType Tests [Fact] public void Builder_WithUpdateTypesMass_BuildsPipeline() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); pipeline.PipelineName.ShouldBe("TestTable"); } [Fact] public void Builder_WithUpdateTypesDaily_BuildsPipeline() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Daily) .Build(); // Assert pipeline.ShouldNotBeNull(); pipeline.PipelineName.ShouldBe("TestTable"); } [Fact] public void Builder_WithUpdateTypesHourly_BuildsPipeline() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); pipeline.PipelineName.ShouldBe("TestTable"); } [Fact] public void Builder_WithUpdateTypesMass_UsesMassQuery() { // Arrange - config with massQuery should use it for Mass update type var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithUpdateTypesDaily_UsesRegularQuery() { // Arrange - Daily should use regular query with date filtering var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Daily) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithUpdateTypesMass_AppliesPrePurgeFromScheduleConfig() { // Arrange - Mass schedule should have prePurge=true from defaults var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - should not throw and should include truncate pre-script var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithUpdateTypesMass_AppliesReIndexFromScheduleConfig() { // Arrange - Mass schedule should have reIndex=true from defaults var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - should not throw and should include reindex post-script var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithUpdateTypesHourly_UsesUpdateWhenFromDefaults() { // Arrange - Hourly should use updateWhen from defaults var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_DefaultMode_IsHourly() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - don't call WithUpdateType() var pipeline = factory.ForTable("TestTable") .Build(); // Assert - should work because hourly mode is defined pipeline.ShouldNotBeNull(); } #endregion #region Builder WithMinimumDate Tests [Fact] public void Builder_WithMinimumDate_OverridesConfigOffset() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); var customDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); // Act - should not throw even though we're overriding var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .WithMinimumDate(customDate) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithNullMinimumDate_UsesConfigOffset() { // Arrange var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - null minDt means use config offset var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .WithMinimumDate(null) .Build(); // Assert pipeline.ShouldNotBeNull(); } #endregion #region Config Validation Tests [Fact] public void Validate_ConfigMissingSchedules_ThrowsInvalidOperationException() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), null, new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), null, // Schedules - null means invalid null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); // Act & Assert var ex = Should.Throw(() => CreateFactory(config)); ex.Message.ShouldContain("must define 'schedules'"); } [Fact] public void Validate_ConfigWithRuntimeParameter_ThrowsNotSupportedException() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), null, new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test WHERE Id = @Id", new Dictionary { ["id"] = new ParameterConfig("@Id", null, "runtime", null) }), new PipelineSchedules { Mass = new ScheduleConfig(), Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); // Act & Assert var ex = Should.Throw(() => CreateFactory(config)); ex.Message.ShouldContain("runtime parameter source is not yet supported"); } #endregion #region Destination Type Tests [Fact] public void Builder_MassMode_WithPrePurge_UsesBulkImport() { // Arrange - Mass with prePurge defaults to bulkImport var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - should use bulkImport for mass mode with prePurge var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_HourlyMode_UsesBulkMerge() { // Arrange - Hourly without prePurge uses bulkMerge var config = CreateValidConfigWithSchedules(); var factory = CreateFactory(config); // Act - should use bulkMerge for hourly mode var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_BulkMergeWithoutMatchColumns_ThrowsInvalidOperationException() { // Arrange - bulkMerge needs matchColumns var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", null, null), // No matchColumns! null, null) }); var factory = CreateFactory(config); // Act & Assert var ex = Should.Throw(() => factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) // Uses bulkMerge .Build()); ex.Message.ShouldContain("matchColumns required for bulkMerge"); } #endregion #region Parameter Tests [Fact] public void Builder_WithOffsetParameter_CreatesSource() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test WHERE UpdateDt >= @MinDt", new Dictionary { ["minDt"] = new ParameterConfig("@MinDt", null, "offset", null) }), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithJdeJulianParameter_CreatesSource() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("jde", "SELECT * FROM Test WHERE UPMJ >= :dateUpdated", new Dictionary { ["minDt"] = new ParameterConfig(":dateUpdated", "jdeJulian", "offset", null) }), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithStaticParameter_UsesConfiguredValue() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test WHERE Status = @Status", new Dictionary { ["status"] = new ParameterConfig("@Status", null, "static", "Active") }), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithStaticParameterNoValue_ThrowsInvalidOperationException() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test WHERE Status = @Status", new Dictionary { ["status"] = new ParameterConfig("@Status", null, "static", null) // No value! }), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act & Assert - must provide minDt for parameters to be processed var ex = Should.Throw(() => factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .WithMinimumDate(DateTime.UtcNow.AddDays(-1)) .Build()); ex.Message.ShouldContain("Static parameter '@Status' requires a value"); } #endregion #region Script Tests [Fact] public void Builder_WithPrePurge_AddsTruncateScript() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithReIndex_AddsRebuildScript() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true, ReIndex = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithPreScripts_AddsConfiguredScripts() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), ["EXEC sp_BeforeSync"], null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } [Fact] public void Builder_WithPostScripts_AddsConfiguredScripts() { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, ["UPDATE TestTable SET ProcessedFlag = 1 WHERE ProcessedFlag IS NULL"]) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } #endregion #region Connection Type Tests [Theory] [InlineData("jde")] [InlineData("cms")] [InlineData("lotfinder")] public void Builder_WithValidConnectionType_BuildsPipeline(string connectionType) { // Arrange var config = new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig(connectionType, "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Mass) .Build(); // Assert pipeline.ShouldNotBeNull(); } #endregion #region Settings Tests [Fact] public void Factory_WithNullSettings_UsesDefaults() { // Arrange - null settings should use defaults var config = new PipelinesRoot( null, // Null settings new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test", null), new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); var factory = CreateFactory(config); // Act var pipeline = factory.ForTable("TestTable") .WithUpdateType(UpdateTypes.Hourly) .Build(); // Assert pipeline.ShouldNotBeNull(); } #endregion #region Helper Methods private PipelinesRoot CreateValidConfigWithSchedules() { return new PipelinesRoot( new PipelineSettings("UTC"), new ScheduleDefaults(), new Dictionary { ["TestTable"] = new PipelineConfig( new SourceConfig("lotfinder", "SELECT * FROM Test WHERE UpdateDt >= @MinDt", new Dictionary { ["minDt"] = new ParameterConfig("@MinDt", null, "offset", null) }, "SELECT * FROM Test"), // MassQuery new PipelineSchedules { Mass = new ScheduleConfig { PrePurge = true, ReIndex = true }, Daily = new ScheduleConfig(), Hourly = new ScheduleConfig() }, null, // Transformers new DestinationConfig("TestTable", ["Id"], null), null, null) }); } private EtlPipelineFactory CreateFactory(PipelinesRoot config) { return new EtlPipelineFactory(_connectionFactory, config, _logger); } #endregion }