diff --git a/NEW/src/JdeScoping.Api/DependencyInjection.cs b/NEW/src/JdeScoping.Api/DependencyInjection.cs
index 15a292f..799b220 100644
--- a/NEW/src/JdeScoping.Api/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.Api/DependencyInjection.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
using JdeScoping.Api.Hubs;
-using JdeScoping.Core.Options;
+using JdeScoping.Api.Options;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
diff --git a/NEW/src/JdeScoping.Core/Options/AuthOptions.cs b/NEW/src/JdeScoping.Api/Options/AuthOptions.cs
similarity index 52%
rename from NEW/src/JdeScoping.Core/Options/AuthOptions.cs
rename to NEW/src/JdeScoping.Api/Options/AuthOptions.cs
index af7d773..8e05484 100644
--- a/NEW/src/JdeScoping.Core/Options/AuthOptions.cs
+++ b/NEW/src/JdeScoping.Api/Options/AuthOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.Core.Options;
+namespace JdeScoping.Api.Options;
///
/// Authentication configuration options
@@ -10,12 +10,6 @@ public class AuthOptions
///
public const string SectionName = "Auth";
- ///
- /// Enable fake authentication for development.
- /// When true, any credentials are accepted.
- ///
- public bool UseFakeAuth { get; set; } = false;
-
///
/// Name of the authentication cookie.
///
@@ -25,10 +19,4 @@ public class AuthOptions
/// Cookie expiration in minutes (default: 8 hours).
///
public int CookieExpirationMinutes { get; set; } = 480;
-
- ///
- /// Optional list of usernames that bypass group check.
- /// Use sparingly for admin/testing purposes.
- ///
- public string[] AdminBypassUsers { get; set; } = [];
}
diff --git a/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs b/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs
deleted file mode 100644
index 3932cb4..0000000
--- a/NEW/src/JdeScoping.Core/Options/DataSyncOptions.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-namespace JdeScoping.Core.Options;
-
-///
-/// Configuration options for data synchronization background jobs.
-///
-public class DataSyncOptions
-{
- ///
- /// Configuration section name in appsettings.json.
- ///
- public const string SectionName = "DataSync";
-
- ///
- /// Cron schedule for mass (full) data refresh. Empty string disables the schedule.
- ///
- public string MassRefreshCronSchedule { get; set; } = "0 0 6 * * SAT";
-
- ///
- /// Cron schedule for daily incremental data refresh. Empty string disables the schedule.
- ///
- public string DailyRefreshCronSchedule { get; set; } = "0 0 4 * * *";
-
- ///
- /// Cron schedule for hourly data refresh. Empty string disables the schedule.
- ///
- public string HourlyRefreshCronSchedule { get; set; } = "0 0 * * * *";
-
- ///
- /// Maximum number of concurrent update operations.
- ///
- public int MaxConcurrentUpdates { get; set; } = 4;
-}
diff --git a/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs b/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs
deleted file mode 100644
index b15831f..0000000
--- a/NEW/src/JdeScoping.Core/Options/ExcelExportOptions.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace JdeScoping.Core.Options;
-
-///
-/// Configuration options for Excel export functionality.
-///
-public class ExcelExportOptions
-{
- ///
- /// Configuration section name in appsettings.json.
- ///
- public const string SectionName = "ExcelExport";
-
- ///
- /// Directory for temporary Excel files.
- ///
- public string TempDirectory { get; set; } = "/tmp/lotfinder";
-
- ///
- /// Maximum number of rows per Excel sheet.
- ///
- public int MaxRowsPerSheet { get; set; } = 1048576;
-
- ///
- /// Default date format for Excel cells.
- ///
- public string DefaultDateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
-}
diff --git a/NEW/src/JdeScoping.Core/Options/SearchOptions.cs b/NEW/src/JdeScoping.Core/Options/SearchOptions.cs
deleted file mode 100644
index 224263e..0000000
--- a/NEW/src/JdeScoping.Core/Options/SearchOptions.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace JdeScoping.Core.Options;
-
-public class SearchOptions
-{
- public int MaxResultRows { get; set; } = 100000;
- public int TimeoutSeconds { get; set; } = 300;
- public int MaxConcurrentSearches { get; set; } = 5;
-}
diff --git a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
index ca64e7a..9218a87 100644
--- a/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.DataAccess/DependencyInjection.cs
@@ -1,7 +1,6 @@
using JdeScoping.Core.Interfaces;
-using JdeScoping.Core.Options;
using JdeScoping.DataAccess;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.QueryBuilders;
diff --git a/NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs
similarity index 93%
rename from NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs
rename to NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs
index 66c3857..39d3c02 100644
--- a/NEW/src/JdeScoping.DataAccess/Configuration/DataAccessOptions.cs
+++ b/NEW/src/JdeScoping.DataAccess/Options/DataAccessOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.DataAccess.Configuration;
+namespace JdeScoping.DataAccess.Options;
///
/// Configuration options for the data access layer.
diff --git a/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs
new file mode 100644
index 0000000..bf66979
--- /dev/null
+++ b/NEW/src/JdeScoping.DataAccess/Options/SearchOptions.cs
@@ -0,0 +1,27 @@
+namespace JdeScoping.DataAccess.Options;
+
+///
+/// Configuration options for search operations.
+///
+public class SearchOptions
+{
+ ///
+ /// Configuration section name in appsettings.json.
+ ///
+ public const string SectionName = "Search";
+
+ ///
+ /// Maximum number of result rows to return.
+ ///
+ public int MaxResultRows { get; set; } = 100000;
+
+ ///
+ /// Search query timeout in seconds.
+ ///
+ public int TimeoutSeconds { get; set; } = 300;
+
+ ///
+ /// Maximum number of concurrent search operations.
+ ///
+ public int MaxConcurrentSearches { get; set; } = 5;
+}
diff --git a/NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs
similarity index 91%
rename from NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs
rename to NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs
index d8aa8dc..98b983e 100644
--- a/NEW/src/JdeScoping.DataAccess/Configuration/SearchProcessingConfiguration.cs
+++ b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingConfiguration.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.DataAccess.Configuration;
+namespace JdeScoping.DataAccess.Options;
///
/// Configuration options for search processing.
diff --git a/NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs
similarity index 91%
rename from NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs
rename to NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs
index 734916d..c3bd95a 100644
--- a/NEW/src/JdeScoping.Core/Options/SearchProcessingOptions.cs
+++ b/NEW/src/JdeScoping.DataAccess/Options/SearchProcessingOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.Core.Options;
+namespace JdeScoping.DataAccess.Options;
///
/// Configuration options for search processing background service.
diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs
index 7dcc9b2..69dbf95 100644
--- a/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs
+++ b/NEW/src/JdeScoping.DataAccess/Repositories/CmsRepository.cs
@@ -2,7 +2,7 @@ using System.Runtime.CompilerServices;
using Dapper;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Queries;
using Microsoft.Extensions.Logging;
diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs
index b4bbf16..21bb460 100644
--- a/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs
+++ b/NEW/src/JdeScoping.DataAccess/Repositories/JdeRepository.cs
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using Dapper;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
diff --git a/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs b/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs
index db186ff..6881f2e 100644
--- a/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs
+++ b/NEW/src/JdeScoping.DataAccess/Repositories/LotFinderRepository.cs
@@ -1,6 +1,6 @@
using System.Data;
using JdeScoping.Core.Interfaces;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Data.SqlClient;
diff --git a/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs b/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs
index a8c54de..6c3c9ae 100644
--- a/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs
+++ b/NEW/src/JdeScoping.DataAccess/Services/SearchProcessor.cs
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using Dapper;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.Results;
diff --git a/NEW/src/JdeScoping.DataSync/DataSyncService.cs b/NEW/src/JdeScoping.DataSync/DataSyncService.cs
index a28be2e..88f92ee 100644
--- a/NEW/src/JdeScoping.DataSync/DataSyncService.cs
+++ b/NEW/src/JdeScoping.DataSync/DataSyncService.cs
@@ -1,4 +1,4 @@
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
diff --git a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs
index cd2c8a4..244806b 100644
--- a/NEW/src/JdeScoping.DataSync/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.DataSync/DependencyInjection.cs
@@ -4,7 +4,7 @@ using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.Quality;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.DataSync;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Configuration.MergeConfigurations;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Fetchers.Cms;
diff --git a/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs b/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs
index 568eba1..ac854f7 100644
--- a/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs
+++ b/NEW/src/JdeScoping.DataSync/Models/DataUpdateTask.cs
@@ -1,5 +1,5 @@
using JdeScoping.Core.Models.Enums;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
namespace JdeScoping.DataSync.Models;
diff --git a/NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs b/NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs
similarity index 94%
rename from NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs
rename to NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs
index 10f67d1..1b10e26 100644
--- a/NEW/src/JdeScoping.DataSync/Configuration/DataSourceConfig.cs
+++ b/NEW/src/JdeScoping.DataSync/Options/DataSourceConfig.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.DataSync.Configuration;
+namespace JdeScoping.DataSync.Options;
///
/// Configuration for a single data source table sync.
diff --git a/NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs b/NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs
similarity index 94%
rename from NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs
rename to NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs
index 4556147..45dee4f 100644
--- a/NEW/src/JdeScoping.DataSync/Configuration/DataSyncOptions.cs
+++ b/NEW/src/JdeScoping.DataSync/Options/DataSyncOptions.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
-namespace JdeScoping.DataSync.Configuration;
+namespace JdeScoping.DataSync.Options;
///
/// Configuration options for the data synchronization service.
diff --git a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs
index 086feff..c1fcf91 100644
--- a/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs
+++ b/NEW/src/JdeScoping.DataSync/Services/ScheduleChecker.cs
@@ -1,7 +1,7 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Infrastructure;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using Microsoft.Extensions.Logging;
diff --git a/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs b/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs
index 0467e8f..3ead9ee 100644
--- a/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs
+++ b/NEW/src/JdeScoping.DataSync/Services/SyncOrchestrator.cs
@@ -1,4 +1,4 @@
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
diff --git a/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs b/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs
index d8ca2a1..f2e056e 100644
--- a/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs
+++ b/NEW/src/JdeScoping.DataSync/Services/TableSyncOperation.cs
@@ -4,7 +4,7 @@ using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Interfaces;
using JdeScoping.DataAccess.Interfaces;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Telemetry;
diff --git a/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs b/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs
index d0b0128..93d2dcd 100644
--- a/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs
+++ b/NEW/src/JdeScoping.ExcelIO/DependencyInjection.cs
@@ -1,6 +1,6 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.ExcelIO;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Parsing;
diff --git a/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs b/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs
index f58b51b..8f3bff6 100644
--- a/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs
+++ b/NEW/src/JdeScoping.ExcelIO/ExcelExportService.cs
@@ -2,7 +2,7 @@ using System.Reflection;
using ClosedXML.Excel;
using JdeScoping.Core.Interfaces;
using JdeScoping.ExcelIO.Attributes;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Models.Reporting;
diff --git a/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs b/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs
index 29d9024..2d1971d 100644
--- a/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs
+++ b/NEW/src/JdeScoping.ExcelIO/Generators/CriteriaSheetGenerator.cs
@@ -1,5 +1,5 @@
using ClosedXML.Excel;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Options;
diff --git a/NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs b/NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs
similarity index 93%
rename from NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs
rename to NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs
index 13b35c4..988ce6e 100644
--- a/NEW/src/JdeScoping.ExcelIO/Configuration/ExcelExportOptions.cs
+++ b/NEW/src/JdeScoping.ExcelIO/Options/ExcelExportOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.ExcelIO.Configuration;
+namespace JdeScoping.ExcelIO.Options;
///
/// Configuration options for Excel export functionality.
diff --git a/NEW/src/JdeScoping.Host/Program.cs b/NEW/src/JdeScoping.Host/Program.cs
index a30149b..6ebbe58 100644
--- a/NEW/src/JdeScoping.Host/Program.cs
+++ b/NEW/src/JdeScoping.Host/Program.cs
@@ -1,7 +1,9 @@
using JdeScoping.Api;
using JdeScoping.Core.Interfaces;
-using JdeScoping.Core.Options;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
+using JdeScoping.DataSync.Options;
+using JdeScoping.ExcelIO.Options;
+using JdeScoping.Infrastructure.Options;
using JdeScoping.Database;
using Microsoft.Extensions.Options;
@@ -68,7 +70,6 @@ static void ValidateServices(IServiceProvider services)
// Validate Options classes are bound
_ = provider.GetRequiredService>();
_ = provider.GetRequiredService>();
- _ = provider.GetRequiredService>();
_ = provider.GetRequiredService>();
_ = provider.GetRequiredService>();
_ = provider.GetRequiredService>();
diff --git a/NEW/src/JdeScoping.Host/appsettings.json b/NEW/src/JdeScoping.Host/appsettings.json
index f17ad99..91d5cd7 100644
--- a/NEW/src/JdeScoping.Host/appsettings.json
+++ b/NEW/src/JdeScoping.Host/appsettings.json
@@ -112,16 +112,16 @@
]
},
"Auth": {
- "UseFakeAuth": false,
"CookieName": "ScopingTool.Auth",
- "CookieExpirationMinutes": 480,
- "AdminBypassUsers": []
+ "CookieExpirationMinutes": 480
},
"Ldap": {
"ServerUrls": ["ldap.corp.example.com"],
"GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com",
"SearchBase": "DC=corp,DC=example,DC=com",
- "ConnectionTimeoutSeconds": 30
+ "ConnectionTimeoutSeconds": 30,
+ "UseFakeAuth": false,
+ "AdminBypassUsers": []
},
"ExcelExport": {
"TempDirectory": "/tmp/lotfinder",
diff --git a/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs b/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs
index 84f6890..e442ae9 100644
--- a/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs
+++ b/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs
@@ -2,7 +2,7 @@ using System.DirectoryServices.Protocols;
using System.Net;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
-using JdeScoping.Core.Options;
+using JdeScoping.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -16,16 +16,13 @@ public sealed class LdapAuthService : IAuthService
private const string LdapLookupFormat = "(sAMAccountName={0})";
private readonly LdapOptions _options;
- private readonly AuthOptions _authOptions;
private readonly ILogger _logger;
public LdapAuthService(
IOptions options,
- IOptions authOptions,
ILogger logger)
{
_options = options.Value;
- _authOptions = authOptions.Value;
_logger = logger;
}
@@ -41,7 +38,7 @@ public sealed class LdapAuthService : IAuthService
}
// Check if user is in admin bypass list
- var isAdminBypass = _authOptions.AdminBypassUsers
+ var isAdminBypass = _options.AdminBypassUsers
.Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase));
// Try each configured LDAP server
diff --git a/NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs b/NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs
similarity index 90%
rename from NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs
rename to NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs
index 71c7ce1..3e39b30 100644
--- a/NEW/src/JdeScoping.Core/Options/DataSourceOptions.cs
+++ b/NEW/src/JdeScoping.Infrastructure/Options/DataSourceOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.Core.Options;
+namespace JdeScoping.Infrastructure.Options;
///
/// Configuration options for data source selection (Oracle vs file-based).
diff --git a/NEW/src/JdeScoping.Core/Options/LdapOptions.cs b/NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs
similarity index 67%
rename from NEW/src/JdeScoping.Core/Options/LdapOptions.cs
rename to NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs
index 8c0ab7e..0fff2b6 100644
--- a/NEW/src/JdeScoping.Core/Options/LdapOptions.cs
+++ b/NEW/src/JdeScoping.Infrastructure/Options/LdapOptions.cs
@@ -1,4 +1,4 @@
-namespace JdeScoping.Core.Options;
+namespace JdeScoping.Infrastructure.Options;
///
/// LDAP configuration options for authentication
@@ -32,4 +32,16 @@ public class LdapOptions
/// Connection timeout in seconds.
///
public int ConnectionTimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Enable fake authentication for development.
+ /// When true, any credentials are accepted.
+ ///
+ public bool UseFakeAuth { get; set; } = false;
+
+ ///
+ /// Optional list of usernames that bypass group check.
+ /// Use sparingly for admin/testing purposes.
+ ///
+ public string[] AdminBypassUsers { get; set; } = [];
}
diff --git a/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs b/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs
index 701e78d..7c67719 100644
--- a/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs
+++ b/NEW/src/JdeScoping.Infrastructure/Sources/Cms/CmsFileDataSource.cs
@@ -3,7 +3,7 @@ using System.Text.Json;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
-using JdeScoping.Core.Options;
+using JdeScoping.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Sources.Cms;
diff --git a/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs b/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs
index da216a5..77bb346 100644
--- a/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs
+++ b/NEW/src/JdeScoping.Infrastructure/Sources/Jde/JdeFileDataSource.cs
@@ -5,7 +5,7 @@ using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.WorkOrders;
-using JdeScoping.Core.Options;
+using JdeScoping.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Sources.Jde;
diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs
index b410ae2..d21eb10 100644
--- a/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs
+++ b/NEW/tests/JdeScoping.DataAccess.Tests/CmsRepositoryTests.cs
@@ -1,4 +1,4 @@
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
@@ -24,7 +24,7 @@ public class CmsRepositoryTests
{
_connectionFactory = Substitute.For();
_logger = Substitute.For>();
- _options = Options.Create(new DataAccessOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
MisDataTimeoutSeconds = 60000
@@ -197,7 +197,7 @@ public class CmsRepositoryTests
public void Constructor_UsesMisDataTimeout()
{
// Arrange
- var customOptions = Options.Create(new DataAccessOptions
+ var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
MisDataTimeoutSeconds = 999999
});
@@ -214,7 +214,7 @@ public class CmsRepositoryTests
public void Constructor_DefaultMisDataTimeout_Is60000Seconds()
{
// Arrange
- var defaultOptions = Options.Create(new DataAccessOptions());
+ var defaultOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions());
// Act
var repository = new CmsRepository(_connectionFactory, _logger, defaultOptions);
diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs
index 1364439..da1bd82 100644
--- a/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs
+++ b/NEW/tests/JdeScoping.DataAccess.Tests/JdeRepositoryTests.cs
@@ -1,4 +1,4 @@
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
@@ -24,7 +24,7 @@ public class JdeRepositoryTests
{
_connectionFactory = Substitute.For();
_logger = Substitute.For>();
- _options = Options.Create(new DataAccessOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
LotUsageTimeoutSeconds = 60,
@@ -637,7 +637,7 @@ public class JdeRepositoryTests
public void Constructor_UsesConfiguredSchemas()
{
// Arrange
- var customOptions = Options.Create(new DataAccessOptions
+ var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
ProductionSchema = "CUSTOM_PROD",
ArchiveSchema = "CUSTOM_ARC",
@@ -656,7 +656,7 @@ public class JdeRepositoryTests
public void Constructor_UsesConfiguredTimeouts()
{
// Arrange
- var customOptions = Options.Create(new DataAccessOptions
+ var customOptions = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 120,
LotUsageTimeoutSeconds = 999999
diff --git a/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs b/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs
index ffd298c..955d24e 100644
--- a/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs
+++ b/NEW/tests/JdeScoping.DataAccess.Tests/LotFinderRepositoryTests.cs
@@ -3,7 +3,7 @@ using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
-using JdeScoping.DataAccess.Configuration;
+using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
@@ -29,7 +29,7 @@ public class LotFinderRepositoryTests
{
_connectionFactory = Substitute.For();
_logger = Substitute.For>();
- _options = Options.Create(new DataAccessOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
RebuildIndexTimeoutSeconds = 60
diff --git a/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs b/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs
index cb04bc1..06e3d19 100644
--- a/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.IntegrationTests/TableSyncOperationTests.cs
@@ -4,7 +4,7 @@ using System.Runtime.CompilerServices;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.Enums;
using JdeScoping.DataAccess.Interfaces;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.IntegrationTests.Infrastructure;
using JdeScoping.DataSync.Models;
@@ -316,7 +316,7 @@ public class TableSyncOperationTests : IAsyncLifetime
return new MassInsertResult(records.Count, TimeSpan.Zero, true);
});
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
BatchSize = 1000,
BulkCopyBatchSize = 100
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs
index fcdaa6f..827aa2b 100644
--- a/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.Tests/DataSyncServiceTests.cs
@@ -1,5 +1,5 @@
using System.Diagnostics.Metrics;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
@@ -22,7 +22,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenDisabled_ExitsImmediately()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = false
});
@@ -56,7 +56,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenEnabled_StartsAndCanBeStopped()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(100)
@@ -106,7 +106,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_GracefulShutdown_CompletesCleanly()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10) // Long interval
@@ -152,7 +152,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_AtStartup_CallsCloseOpenUpdateEntries()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -199,7 +199,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenCloseOpenEntriesFindsEntries_LogsAndContinues()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -248,7 +248,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -301,7 +301,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_CallsOrchestratorForParallelExecution()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50),
@@ -349,7 +349,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenOrchestratorThrows_ContinuesNextCycle()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -404,7 +404,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenCancelled_StopsGracefully()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10)
@@ -462,7 +462,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_PassesCancellationTokenToOrchestrator()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -510,7 +510,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenCancelledDuringDelay_ExitsCleanly()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMinutes(5) // Long delay
@@ -562,7 +562,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_UsesNewScopePerCycle()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
@@ -610,7 +610,7 @@ public class DataSyncServiceTests
public async Task ExecuteAsync_WhenSyncFails_ContinuesRunning()
{
// Arrange
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs
index a5ba12f..2dce024 100644
--- a/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.Tests/ScheduleCheckerTests.cs
@@ -1,7 +1,7 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Infrastructure;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Services;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,7 +23,7 @@ public class ScheduleCheckerTests
public ScheduleCheckerTests()
{
_repository = Substitute.For();
- _options = Options.Create(new DataSyncOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
LookbackMultiplier = 3,
DataSources = []
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs
index a6a9539..9ef5481 100644
--- a/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.Tests/SyncOrchestratorTests.cs
@@ -1,6 +1,6 @@
using System.Diagnostics.Metrics;
using JdeScoping.Core.Models.Enums;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
@@ -27,7 +27,7 @@ public class SyncOrchestratorTests
public SyncOrchestratorTests()
{
_scheduleChecker = Substitute.For();
- _options = Options.Create(new DataSyncOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
MaxDegreeOfParallelism = 4
});
@@ -176,7 +176,7 @@ public class SyncOrchestratorTests
public async Task ExecutePendingSyncsAsync_RespectsMaxDegreeOfParallelism()
{
// Arrange: Create 10 tasks but limit parallelism to 2
- var options = Options.Create(new DataSyncOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
MaxDegreeOfParallelism = 2
});
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs
index 33ee20a..7c12ea4 100644
--- a/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs
+++ b/NEW/tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs
@@ -5,7 +5,7 @@ using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.DataAccess.Interfaces;
-using JdeScoping.DataSync.Configuration;
+using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
@@ -40,7 +40,7 @@ public class TableSyncOperationTests
_bulkMergeHelper = Substitute.For();
_configRegistry = Substitute.For();
- _options = Options.Create(new DataSyncOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
BatchSize = 1000,
BulkCopyBatchSize = 100
diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs
index de1702a..39b4d03 100644
--- a/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs
+++ b/NEW/tests/JdeScoping.ExcelIO.Tests/CriteriaSheetGeneratorTests.cs
@@ -1,5 +1,5 @@
using ClosedXML.Excel;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Models.Reporting;
@@ -16,7 +16,7 @@ public class CriteriaSheetGeneratorTests
public CriteriaSheetGeneratorTests()
{
- _options = Options.Create(new ExcelExportOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions
{
CriteriaSheetPassword = "TestPassword"
});
diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs
index e59e712..f4e148b 100644
--- a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs
+++ b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportIntegrationTests.cs
@@ -1,5 +1,5 @@
using ClosedXML.Excel;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Models.Reporting;
@@ -23,7 +23,7 @@ public class ExcelExportIntegrationTests
public ExcelExportIntegrationTests()
{
_logger = Substitute.For>();
- _options = Options.Create(new ExcelExportOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions
{
CriteriaSheetPassword = "TestCriteriaPass",
DataSheetPassword = "TestDataPass"
diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs
index 9e12346..15fc283 100644
--- a/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs
+++ b/NEW/tests/JdeScoping.ExcelIO.Tests/ExcelExportServiceTests.cs
@@ -1,5 +1,5 @@
using ClosedXML.Excel;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Models.Reporting;
@@ -20,7 +20,7 @@ public class ExcelExportServiceTests
public ExcelExportServiceTests()
{
_logger = Substitute.For>();
- _options = Options.Create(new ExcelExportOptions
+ _options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions
{
CriteriaSheetPassword = "TestCriteriaPass",
DataSheetPassword = "TestDataPass"
diff --git a/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs b/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs
index a0f466e..368abb0 100644
--- a/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs
+++ b/NEW/tests/JdeScoping.ExcelIO.Tests/LegacyComparisonTests.cs
@@ -1,5 +1,5 @@
using ClosedXML.Excel;
-using JdeScoping.ExcelIO.Configuration;
+using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
@@ -24,7 +24,7 @@ public class LegacyComparisonTests
public LegacyComparisonTests()
{
var logger = Substitute.For>();
- var options = Options.Create(new ExcelExportOptions
+ var options = Microsoft.Extensions.Options.Options.Create(new ExcelExportOptions
{
CriteriaSheetPassword = "JDE_SCOPING_TOOL_PASS",
DataSheetPassword = "JDESCOPINGTOOL"
diff --git a/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs b/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs
index 73cae2f..867a8fd 100644
--- a/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs
+++ b/NEW/tests/JdeScoping.Infrastructure.Tests/Integration/LdapIntegrationTests.cs
@@ -1,4 +1,4 @@
-using JdeScoping.Core.Options;
+using JdeScoping.Infrastructure.Options;
using JdeScoping.Infrastructure.Auth;
using JdeScoping.Infrastructure.Tests.Helpers;
using Microsoft.Extensions.Logging;
@@ -52,19 +52,16 @@ public class LdapIntegrationTests
{
// Arrange
var testUser = MockLdapServer.ValidGroupMemberUser;
- var ldapOptions = Options.Create(new LdapOptions
+ var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = MockLdapServer.FakeServerUrls,
GroupDn = MockLdapServer.TestGroupDn,
SearchBase = MockLdapServer.TestSearchBase,
- ConnectionTimeoutSeconds = 1 // Fast timeout for test
- });
- var authOptions = Options.Create(new AuthOptions
- {
+ ConnectionTimeoutSeconds = 1, // Fast timeout for test
AdminBypassUsers = []
});
- var service = new LdapAuthService(ldapOptions, authOptions, _logger);
+ var service = new LdapAuthService(ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync(testUser.Username, testUser.Password);
@@ -105,19 +102,16 @@ public class LdapIntegrationTests
{
// Arrange
var testUser = MockLdapServer.ValidNotInGroupUser;
- var ldapOptions = Options.Create(new LdapOptions
+ var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = MockLdapServer.FakeServerUrls,
GroupDn = MockLdapServer.TestGroupDn,
SearchBase = MockLdapServer.TestSearchBase,
- ConnectionTimeoutSeconds = 1
- });
- var authOptions = Options.Create(new AuthOptions
- {
+ ConnectionTimeoutSeconds = 1,
AdminBypassUsers = []
});
- var service = new LdapAuthService(ldapOptions, authOptions, _logger);
+ var service = new LdapAuthService(ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync(testUser.Username, testUser.Password);
@@ -152,19 +146,16 @@ public class LdapIntegrationTests
{
// Arrange
var testUser = MockLdapServer.InvalidCredentialsUser;
- var ldapOptions = Options.Create(new LdapOptions
+ var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = MockLdapServer.FakeServerUrls,
GroupDn = MockLdapServer.TestGroupDn,
SearchBase = MockLdapServer.TestSearchBase,
- ConnectionTimeoutSeconds = 1
- });
- var authOptions = Options.Create(new AuthOptions
- {
+ ConnectionTimeoutSeconds = 1,
AdminBypassUsers = []
});
- var service = new LdapAuthService(ldapOptions, authOptions, _logger);
+ var service = new LdapAuthService(ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync(testUser.Username, testUser.Password);
@@ -205,19 +196,16 @@ public class LdapIntegrationTests
public async Task AuthenticateAsync_AllServersFail_ReturnsConnectionError()
{
// Arrange
- var ldapOptions = Options.Create(new LdapOptions
+ var ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = MockLdapServer.FakeServerUrls, // 3 fake servers that will all fail
GroupDn = MockLdapServer.TestGroupDn,
SearchBase = MockLdapServer.TestSearchBase,
- ConnectionTimeoutSeconds = 1 // Fast timeout for test
- });
- var authOptions = Options.Create(new AuthOptions
- {
+ ConnectionTimeoutSeconds = 1, // Fast timeout for test
AdminBypassUsers = []
});
- var service = new LdapAuthService(ldapOptions, authOptions, _logger);
+ var service = new LdapAuthService(ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync("anyuser", "anypassword");
diff --git a/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs b/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs
index 39d46bc..b46fef4 100644
--- a/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs
+++ b/NEW/tests/JdeScoping.Infrastructure.Tests/Unit/LdapAuthServiceTests.cs
@@ -1,5 +1,5 @@
-using JdeScoping.Core.Options;
using JdeScoping.Infrastructure.Auth;
+using JdeScoping.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
@@ -10,20 +10,16 @@ namespace JdeScoping.Infrastructure.Tests.Unit;
public class LdapAuthServiceTests
{
private readonly IOptions _ldapOptions;
- private readonly IOptions _authOptions;
private readonly ILogger _logger;
public LdapAuthServiceTests()
{
- _ldapOptions = Options.Create(new LdapOptions
+ _ldapOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = ["ldap.test.com"],
GroupDn = "CN=TestGroup,DC=test,DC=com",
SearchBase = "DC=test,DC=com",
- ConnectionTimeoutSeconds = 5
- });
- _authOptions = Options.Create(new AuthOptions
- {
+ ConnectionTimeoutSeconds = 5,
AdminBypassUsers = []
});
_logger = Substitute.For>();
@@ -33,7 +29,7 @@ public class LdapAuthServiceTests
public async Task AuthenticateAsync_EmptyUsername_ReturnsFailure()
{
// Arrange
- var service = new LdapAuthService(_ldapOptions, _authOptions, _logger);
+ var service = new LdapAuthService(_ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync("", "password");
@@ -47,7 +43,7 @@ public class LdapAuthServiceTests
public async Task AuthenticateAsync_EmptyPassword_ReturnsFailure()
{
// Arrange
- var service = new LdapAuthService(_ldapOptions, _authOptions, _logger);
+ var service = new LdapAuthService(_ldapOptions, _logger);
// Act
var result = await service.AuthenticateAsync("user", "");
@@ -61,13 +57,14 @@ public class LdapAuthServiceTests
public async Task AuthenticateAsync_NoServersConfigured_ReturnsConnectionError()
{
// Arrange
- var emptyServerOptions = Options.Create(new LdapOptions
+ var emptyServerOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = [],
GroupDn = "CN=TestGroup,DC=test,DC=com",
- SearchBase = "DC=test,DC=com"
+ SearchBase = "DC=test,DC=com",
+ AdminBypassUsers = []
});
- var service = new LdapAuthService(emptyServerOptions, _authOptions, _logger);
+ var service = new LdapAuthService(emptyServerOptions, _logger);
// Act
var result = await service.AuthenticateAsync("user", "password");
@@ -81,7 +78,7 @@ public class LdapAuthServiceTests
public void GetUserInfoAsync_ThrowsNotSupportedException()
{
// Arrange
- var service = new LdapAuthService(_ldapOptions, _authOptions, _logger);
+ var service = new LdapAuthService(_ldapOptions, _logger);
// Act & Assert
Should.Throw(() => service.GetUserInfoAsync("user").GetAwaiter().GetResult());
@@ -93,11 +90,15 @@ public class LdapAuthServiceTests
// Arrange
// Note: We can't fully test admin bypass without a real LDAP server since bind still happens.
// This test verifies the configuration is recognized by checking that bypass users are configured.
- var authOptionsWithBypass = Options.Create(new AuthOptions
+ var ldapOptionsWithBypass = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
+ ServerUrls = ["ldap.test.com"],
+ GroupDn = "CN=TestGroup,DC=test,DC=com",
+ SearchBase = "DC=test,DC=com",
+ ConnectionTimeoutSeconds = 5,
AdminBypassUsers = ["bypassuser", "adminuser"]
});
- var service = new LdapAuthService(_ldapOptions, authOptionsWithBypass, _logger);
+ var service = new LdapAuthService(ldapOptionsWithBypass, _logger);
// Act - attempt to authenticate the bypass user (will fail LDAP connection, but config is exercised)
var result = await service.AuthenticateAsync("bypassuser", "anypassword");
@@ -113,14 +114,15 @@ public class LdapAuthServiceTests
public async Task AuthenticateAsync_MultipleServersConfigured_TriesEachUntilAllFail()
{
// Arrange
- var multiServerOptions = Options.Create(new LdapOptions
+ var multiServerOptions = Microsoft.Extensions.Options.Options.Create(new LdapOptions
{
ServerUrls = ["ldap1.test.com", "ldap2.test.com", "ldap3.test.com"],
GroupDn = "CN=TestGroup,DC=test,DC=com",
SearchBase = "DC=test,DC=com",
- ConnectionTimeoutSeconds = 1 // Fast timeout for test
+ ConnectionTimeoutSeconds = 1, // Fast timeout for test
+ AdminBypassUsers = []
});
- var service = new LdapAuthService(multiServerOptions, _authOptions, _logger);
+ var service = new LdapAuthService(multiServerOptions, _logger);
// Act
var result = await service.AuthenticateAsync("testuser", "testpassword");
diff --git a/PLANS/2026-01-03-encrypted-login-design.md b/PLANS/2026-01-03-encrypted-login-design.md
new file mode 100644
index 0000000..baa1739
--- /dev/null
+++ b/PLANS/2026-01-03-encrypted-login-design.md
@@ -0,0 +1,134 @@
+# Encrypted Login Design
+
+## Overview
+
+Consolidate login models into Core project and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API.
+
+## Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Encryption algorithm | RSA-OAEP (SHA-256) | Simple direct encryption, suitable for small payloads |
+| Key size | 2048-bit | Industry standard, ~20 year security horizon |
+| Key distribution | Fetch at runtime | Allows key rotation without client redeployment |
+| Key storage | Auto-generate, persist to file | Self-bootstrapping, no manual key management |
+| Key expiration | None | Internal enterprise app, manual rotation if needed |
+
+## New Models (Core)
+
+All in `JdeScoping.Core/Models/Auth/`:
+
+```csharp
+// LoginModel.cs - shared form/request model
+public class LoginModel
+{
+ [Required(ErrorMessage = "Username is required")]
+ public string Username { get; set; } = string.Empty;
+
+ [Required(ErrorMessage = "Password is required")]
+ public string Password { get; set; } = string.Empty;
+}
+
+// LoginResultModel.cs - API response (unencrypted)
+public record LoginResultModel(
+ bool Success,
+ string? ErrorMessage,
+ UserInfo? User);
+
+// EncryptedLoginRequest.cs - encrypted payload wrapper
+public record EncryptedLoginRequest(string EncryptedData);
+
+// PublicKeyResponse.cs - public key endpoint response
+public record PublicKeyResponse(string PublicKeyPem);
+```
+
+## RSA Key Service (Infrastructure)
+
+**Interface:** `JdeScoping.Core/Interfaces/IRsaKeyService.cs`
+```csharp
+public interface IRsaKeyService
+{
+ string GetPublicKeyPem();
+ byte[] Decrypt(byte[] ciphertext);
+}
+```
+
+**Implementation:** `JdeScoping.Infrastructure/Security/RsaKeyService.cs`
+- Auto-generates 2048-bit RSA key on first startup if not exists
+- Persists private key to configurable file path
+- Exports public key as PEM format
+- Decrypts using RSA-OAEP with SHA-256 padding
+
+## API Changes
+
+**New endpoint:**
+```
+GET /api/auth/public-key → PublicKeyResponse
+```
+
+**Modified endpoint:**
+```
+POST /api/auth/login
+Body: EncryptedLoginRequest { EncryptedData: base64 }
+Response: LoginResultModel
+```
+
+Flow:
+1. Receive base64-encoded encrypted data
+2. Decode and decrypt with RSA private key
+3. Deserialize JSON to LoginModel
+4. Authenticate via IAuthService
+5. Return LoginResultModel (unencrypted)
+
+## Client Changes
+
+**New service:** `ICryptoService` / `CryptoService`
+- Uses `Blazor.SubtleCrypto` NuGet package
+- Fetches and caches server public key
+- Encrypts LoginModel JSON with RSA-OAEP
+- Returns base64-encoded ciphertext
+
+**Modified AuthService:**
+- Injects ICryptoService
+- Encrypts LoginModel before sending
+- Sends EncryptedLoginRequest to API
+
+## Files to Delete
+
+- `JdeScoping.Api/Models/LoginRequest.cs`
+- `JdeScoping.Client/Models/LoginModel.cs`
+
+## Dependencies
+
+**JdeScoping.Client.csproj:**
+```xml
+
+```
+
+## DI Registration
+
+**API/Host:**
+```csharp
+services.AddSingleton(sp =>
+ new RsaKeyService(Path.Combine(appDataPath, "rsa-key.bin")));
+```
+
+**Client:**
+```csharp
+services.AddScoped();
+```
+
+## Test Coverage
+
+### RsaKeyServiceTests (Api.Tests)
+- GetPublicKeyPem returns valid PEM format
+- Decrypt with valid ciphertext returns plaintext
+- Constructor loads existing key from file
+- Constructor generates and persists new key if missing
+
+### CryptoServiceTests (Client.Tests)
+- EncryptLoginAsync returns valid base64
+- EncryptLoginAsync caches public key (single HTTP call)
+
+### Integration (optional)
+- Full roundtrip: encrypt on client → decrypt on API → authenticate
diff --git a/PLANS/2026-01-03-encrypted-login-implementation.md b/PLANS/2026-01-03-encrypted-login-implementation.md
new file mode 100644
index 0000000..7b0fe19
--- /dev/null
+++ b/PLANS/2026-01-03-encrypted-login-implementation.md
@@ -0,0 +1,1384 @@
+# Encrypted Login Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Consolidate login models into Core and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API.
+
+**Architecture:** Client fetches server's RSA public key at runtime, encrypts login credentials with RSA-OAEP, sends encrypted payload to API. Server decrypts with private key (auto-generated on first startup, persisted to file). Shared models in Core eliminate duplication.
+
+**Tech Stack:** .NET 10, RSA-OAEP (SHA-256), Blazor.SubtleCrypto (Web Crypto API wrapper), xUnit, NSubstitute, Shouldly
+
+---
+
+## Task 1: Add Shared Auth Models to Core
+
+**Files:**
+- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs`
+- Create: `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs`
+- Create: `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs`
+- Create: `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs`
+
+**Step 1: Create Auth folder and LoginModel**
+
+```csharp
+// NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs
+using System.ComponentModel.DataAnnotations;
+
+namespace JdeScoping.Core.Models.Auth;
+
+///
+/// Login credentials model shared by Client and API.
+///
+public class LoginModel
+{
+ [Required(ErrorMessage = "Username is required")]
+ public string Username { get; set; } = string.Empty;
+
+ [Required(ErrorMessage = "Password is required")]
+ public string Password { get; set; } = string.Empty;
+}
+```
+
+**Step 2: Create LoginResultModel**
+
+```csharp
+// NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs
+namespace JdeScoping.Core.Models.Auth;
+
+///
+/// Result returned from login API endpoint.
+///
+/// Whether authentication succeeded
+/// Error message if failed
+/// User info if successful
+public record LoginResultModel(
+ bool Success,
+ string? ErrorMessage,
+ UserInfo? User);
+```
+
+**Step 3: Create EncryptedLoginRequest**
+
+```csharp
+// NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs
+namespace JdeScoping.Core.Models.Auth;
+
+///
+/// Encrypted login payload sent from client to API.
+///
+/// Base64-encoded RSA-encrypted JSON of LoginModel
+public record EncryptedLoginRequest(string EncryptedData);
+```
+
+**Step 4: Create PublicKeyResponse**
+
+```csharp
+// NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs
+namespace JdeScoping.Core.Models.Auth;
+
+///
+/// Server's RSA public key for client-side encryption.
+///
+/// PEM-encoded RSA public key
+public record PublicKeyResponse(string PublicKeyPem);
+```
+
+**Step 5: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj`
+Expected: Build succeeded
+
+**Step 6: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Core/Models/Auth/
+git commit -m "feat(core): add shared auth models for encrypted login"
+```
+
+---
+
+## Task 2: Add IRsaKeyService Interface to Core
+
+**Files:**
+- Create: `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs`
+
+**Step 1: Create the interface**
+
+```csharp
+// NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
+namespace JdeScoping.Core.Interfaces;
+
+///
+/// RSA key management for login encryption.
+///
+public interface IRsaKeyService
+{
+ ///
+ /// Gets the server's public key in PEM format.
+ ///
+ string GetPublicKeyPem();
+
+ ///
+ /// Decrypts RSA-OAEP encrypted data.
+ ///
+ /// Encrypted bytes
+ /// Decrypted plaintext bytes
+ byte[] Decrypt(byte[] ciphertext);
+}
+```
+
+**Step 2: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
+git commit -m "feat(core): add IRsaKeyService interface"
+```
+
+---
+
+## Task 3: Implement RsaKeyService with Tests (TDD)
+
+**Files:**
+- Create: `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs`
+- Create: `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs`
+
+**Step 1: Write failing test for GetPublicKeyPem**
+
+```csharp
+// NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Infrastructure.Security;
+using Shouldly;
+
+namespace JdeScoping.Infrastructure.Tests.Security;
+
+public class RsaKeyServiceTests : IDisposable
+{
+ private readonly string _testKeyPath;
+
+ public RsaKeyServiceTests()
+ {
+ _testKeyPath = Path.Combine(Path.GetTempPath(), $"test-rsa-key-{Guid.NewGuid()}.bin");
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_testKeyPath))
+ File.Delete(_testKeyPath);
+ }
+
+ [Fact]
+ public void GetPublicKeyPem_ReturnsValidPemFormat()
+ {
+ // Arrange
+ var service = new RsaKeyService(_testKeyPath);
+
+ // Act
+ var pem = service.GetPublicKeyPem();
+
+ // Assert
+ pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
+ pem.ShouldEndWith("-----END PUBLIC KEY-----");
+ }
+
+ [Fact]
+ public void Constructor_WhenKeyFileMissing_GeneratesAndPersistsKey()
+ {
+ // Arrange - ensure file doesn't exist
+ File.Exists(_testKeyPath).ShouldBeFalse();
+
+ // Act
+ _ = new RsaKeyService(_testKeyPath);
+
+ // Assert
+ File.Exists(_testKeyPath).ShouldBeTrue();
+ new FileInfo(_testKeyPath).Length.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public void Constructor_WhenKeyFileExists_LoadsSameKey()
+ {
+ // Arrange - create service to generate key
+ var service1 = new RsaKeyService(_testKeyPath);
+ var publicKey1 = service1.GetPublicKeyPem();
+
+ // Act - create new service instance
+ var service2 = new RsaKeyService(_testKeyPath);
+ var publicKey2 = service2.GetPublicKeyPem();
+
+ // Assert - same key loaded
+ publicKey2.ShouldBe(publicKey1);
+ }
+
+ [Fact]
+ public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
+ {
+ // Arrange
+ var service = new RsaKeyService(_testKeyPath);
+ var plaintext = "Hello, World!"u8.ToArray();
+
+ // Encrypt using public key (simulating what client does)
+ using var rsa = System.Security.Cryptography.RSA.Create();
+ rsa.ImportFromPem(service.GetPublicKeyPem());
+ var ciphertext = rsa.Encrypt(plaintext, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
+
+ // Act
+ var decrypted = service.Decrypt(ciphertext);
+
+ // Assert
+ decrypted.ShouldBe(plaintext);
+ }
+
+ [Fact]
+ public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
+ {
+ // Arrange
+ var service = new RsaKeyService(_testKeyPath);
+ var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 };
+
+ // Act & Assert
+ Should.Throw(
+ () => service.Decrypt(invalidCiphertext));
+ }
+}
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal`
+Expected: FAIL - RsaKeyService class not found
+
+**Step 3: Implement RsaKeyService**
+
+```csharp
+// NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
+using System.Security.Cryptography;
+using JdeScoping.Core.Interfaces;
+
+namespace JdeScoping.Infrastructure.Security;
+
+///
+/// RSA key service that auto-generates and persists keys.
+///
+public class RsaKeyService : IRsaKeyService, IDisposable
+{
+ private readonly RSA _rsa;
+
+ ///
+ /// Creates a new RSA key service.
+ ///
+ /// Path to persist the private key
+ public RsaKeyService(string keyFilePath)
+ {
+ _rsa = RSA.Create(2048);
+
+ if (File.Exists(keyFilePath))
+ {
+ var keyBytes = File.ReadAllBytes(keyFilePath);
+ _rsa.ImportRSAPrivateKey(keyBytes, out _);
+ }
+ else
+ {
+ var privateKey = _rsa.ExportRSAPrivateKey();
+ var directory = Path.GetDirectoryName(keyFilePath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+ File.WriteAllBytes(keyFilePath, privateKey);
+ }
+ }
+
+ ///
+ public string GetPublicKeyPem()
+ => _rsa.ExportSubjectPublicKeyInfoPem();
+
+ ///
+ public byte[] Decrypt(byte[] ciphertext)
+ => _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
+
+ public void Dispose()
+ {
+ _rsa.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal`
+Expected: All 5 tests pass
+
+**Step 5: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
+git add NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
+git commit -m "feat(infrastructure): implement RsaKeyService with tests"
+```
+
+---
+
+## Task 4: Add RsaKeyOptions and DI Registration
+
+**Files:**
+- Create: `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs`
+- Modify: `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs`
+
+**Step 1: Create RsaKeyOptions**
+
+```csharp
+// NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
+namespace JdeScoping.Core.Options;
+
+///
+/// Configuration options for RSA key service.
+///
+public class RsaKeyOptions
+{
+ public const string SectionName = "RsaKey";
+
+ ///
+ /// Path to store the RSA private key file.
+ /// Defaults to "data/rsa-key.bin" relative to app directory.
+ ///
+ public string KeyFilePath { get; set; } = "data/rsa-key.bin";
+}
+```
+
+**Step 2: Update Infrastructure DependencyInjection**
+
+Add to `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs` after existing using statements:
+
+```csharp
+using JdeScoping.Infrastructure.Security;
+```
+
+Add inside `AddInfrastructure` method, after the auth service registration (around line 60):
+
+```csharp
+ // Register RSA key service for login encryption
+ services.Configure(
+ configuration.GetSection(RsaKeyOptions.SectionName));
+
+ var rsaKeyOptions = configuration
+ .GetSection(RsaKeyOptions.SectionName)
+ .Get() ?? new RsaKeyOptions();
+
+ var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
+ ? rsaKeyOptions.KeyFilePath
+ : Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
+
+ services.AddSingleton(new RsaKeyService(keyPath));
+```
+
+**Step 3: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
+git add NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
+git commit -m "feat(infrastructure): register RsaKeyService in DI"
+```
+
+---
+
+## Task 5: Update AuthController to Use Encrypted Login
+
+**Files:**
+- Modify: `NEW/src/JdeScoping.Api/Controllers/AuthController.cs`
+- Delete: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
+
+**Step 1: Update AuthController imports and constructor**
+
+Replace the using statements at the top of `AuthController.cs`:
+
+```csharp
+using System.Security.Claims;
+using System.Text.Json;
+using JdeScoping.Api.Extensions;
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Core.Models;
+using JdeScoping.Core.Models.Auth;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+```
+
+Update the constructor to inject IRsaKeyService:
+
+```csharp
+public class AuthController : ApiControllerBase
+{
+ private readonly IAuthService _authService;
+ private readonly IRsaKeyService _rsaKeyService;
+ private readonly ILogger _logger;
+
+ public AuthController(
+ IAuthService authService,
+ IRsaKeyService rsaKeyService,
+ ILogger logger)
+ {
+ _authService = authService;
+ _rsaKeyService = rsaKeyService;
+ _logger = logger;
+ }
+```
+
+**Step 2: Add GetPublicKey endpoint**
+
+Add after the constructor:
+
+```csharp
+ ///
+ /// Gets the server's RSA public key for encrypting login credentials.
+ ///
+ [HttpGet("public-key")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(PublicKeyResponse), StatusCodes.Status200OK)]
+ public ActionResult GetPublicKey()
+ {
+ var publicKeyPem = _rsaKeyService.GetPublicKeyPem();
+ return Ok(new PublicKeyResponse(publicKeyPem));
+ }
+```
+
+**Step 3: Update Login endpoint to accept encrypted payload**
+
+Replace the existing Login method:
+
+```csharp
+ ///
+ /// Authenticates a user with encrypted credentials and creates a session cookie.
+ ///
+ /// Encrypted login credentials
+ /// Cancellation token
+ /// Login result with user info on success
+ [HttpPost("login")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status401Unauthorized)]
+ public async Task> Login(
+ [FromBody] EncryptedLoginRequest request,
+ CancellationToken ct)
+ {
+ LoginModel loginModel;
+ try
+ {
+ var ciphertext = Convert.FromBase64String(request.EncryptedData);
+ var plaintext = _rsaKeyService.Decrypt(ciphertext);
+ loginModel = JsonSerializer.Deserialize(plaintext)
+ ?? throw new InvalidOperationException("Deserialization returned null");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to decrypt login request");
+ return BadRequest(new LoginResultModel(false, "Invalid encrypted payload", null));
+ }
+
+ var result = await _authService.AuthenticateAsync(
+ loginModel.Username, loginModel.Password, ct);
+
+ if (!result.Success)
+ {
+ _logger.LogWarning("Failed login attempt for user {Username}", loginModel.Username);
+ return Unauthorized(new LoginResultModel(false, result.ErrorMessage, null));
+ }
+
+ // Sign out existing session
+ await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ // Create claims identity from user info
+ var identity = ClaimsExtensions.FromUserInfo(result.User!);
+ var principal = new ClaimsPrincipal(identity);
+
+ // Sign in with non-persistent cookie
+ await HttpContext.SignInAsync(
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ principal,
+ new AuthenticationProperties { IsPersistent = false });
+
+ _logger.LogInformation("User {Username} logged in successfully", loginModel.Username);
+ return Ok(new LoginResultModel(true, null, result.User));
+ }
+```
+
+**Step 4: Delete old LoginRequest model**
+
+Delete file: `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
+
+**Step 5: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Api/JdeScoping.Api.csproj`
+Expected: Build succeeded
+
+**Step 6: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Api/Controllers/AuthController.cs
+git rm NEW/src/JdeScoping.Api/Models/LoginRequest.cs
+git commit -m "feat(api): update AuthController for encrypted login"
+```
+
+---
+
+## Task 6: Update AuthController Tests
+
+**Files:**
+- Modify: `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs`
+
+**Step 1: Update imports and add RSA helper**
+
+Replace the entire test file:
+
+```csharp
+// NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using JdeScoping.Api.Controllers;
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Core.Models;
+using JdeScoping.Core.Models.Auth;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+
+namespace JdeScoping.Api.Tests.Controllers;
+
+public class AuthControllerTests
+{
+ private readonly IAuthService _authService;
+ private readonly IRsaKeyService _rsaKeyService;
+ private readonly ILogger _logger;
+ private readonly AuthController _controller;
+ private readonly RSA _testRsa;
+
+ public AuthControllerTests()
+ {
+ _authService = Substitute.For();
+ _rsaKeyService = Substitute.For();
+ _logger = Substitute.For>();
+
+ // Setup test RSA key pair
+ _testRsa = RSA.Create(2048);
+ var publicKeyPem = _testRsa.ExportSubjectPublicKeyInfoPem();
+ _rsaKeyService.GetPublicKeyPem().Returns(publicKeyPem);
+ _rsaKeyService.Decrypt(Arg.Any())
+ .Returns(callInfo =>
+ {
+ var ciphertext = callInfo.Arg();
+ return _testRsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
+ });
+
+ _controller = new AuthController(_authService, _rsaKeyService, _logger);
+ }
+
+ private EncryptedLoginRequest EncryptLoginModel(LoginModel model)
+ {
+ var json = JsonSerializer.Serialize(model);
+ var plaintext = Encoding.UTF8.GetBytes(json);
+ var ciphertext = _testRsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
+ return new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
+ }
+
+ [Fact]
+ public void GetPublicKey_ReturnsPublicKeyPem()
+ {
+ // Act
+ var result = _controller.GetPublicKey();
+
+ // Assert
+ result.Result.ShouldBeOfType();
+ var okResult = (OkObjectResult)result.Result!;
+ var response = okResult.Value.ShouldBeOfType();
+ response.PublicKeyPem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
+ }
+
+ [Fact]
+ public async Task Login_WithValidCredentials_ReturnsLoginResultWithUser()
+ {
+ // Arrange
+ var loginModel = new LoginModel { Username = "testuser", Password = "password123" };
+ var request = EncryptLoginModel(loginModel);
+ var user = new UserInfo
+ {
+ Dn = "CN=testuser,DC=example,DC=com",
+ Username = "testuser",
+ FirstName = "Test",
+ LastName = "User",
+ EmailAddress = "test@example.com",
+ Title = "Developer"
+ };
+ _authService.AuthenticateAsync("testuser", "password123", Arg.Any())
+ .Returns(new AuthResult(true, user, null));
+
+ var httpContext = CreateMockHttpContext();
+ _controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+
+ // Act
+ var result = await _controller.Login(request, CancellationToken.None);
+
+ // Assert
+ result.Result.ShouldBeOfType();
+ var okResult = (OkObjectResult)result.Result!;
+ var loginResult = okResult.Value.ShouldBeOfType();
+ loginResult.Success.ShouldBeTrue();
+ loginResult.User.ShouldNotBeNull();
+ loginResult.User.Username.ShouldBe("testuser");
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidCredentials_Returns401WithError()
+ {
+ // Arrange
+ var loginModel = new LoginModel { Username = "testuser", Password = "wrongpassword" };
+ var request = EncryptLoginModel(loginModel);
+ _authService.AuthenticateAsync("testuser", "wrongpassword", Arg.Any())
+ .Returns(new AuthResult(false, null, "Incorrect username or password"));
+
+ var httpContext = CreateMockHttpContext();
+ _controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+
+ // Act
+ var result = await _controller.Login(request, CancellationToken.None);
+
+ // Assert
+ result.Result.ShouldBeOfType();
+ var unauthorizedResult = (UnauthorizedObjectResult)result.Result!;
+ var loginResult = unauthorizedResult.Value.ShouldBeOfType();
+ loginResult.Success.ShouldBeFalse();
+ loginResult.ErrorMessage.ShouldBe("Incorrect username or password");
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidEncryptedData_ReturnsBadRequest()
+ {
+ // Arrange
+ var request = new EncryptedLoginRequest("not-valid-base64!!!");
+
+ var httpContext = CreateMockHttpContext();
+ _controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+
+ // Act
+ var result = await _controller.Login(request, CancellationToken.None);
+
+ // Assert
+ result.Result.ShouldBeOfType();
+ var badRequestResult = (BadRequestObjectResult)result.Result!;
+ var loginResult = badRequestResult.Value.ShouldBeOfType();
+ loginResult.Success.ShouldBeFalse();
+ loginResult.ErrorMessage.ShouldBe("Invalid encrypted payload");
+ }
+
+ [Fact]
+ public async Task Logout_ClearsAuthentication()
+ {
+ // Arrange
+ var httpContext = CreateAuthenticatedHttpContext("testuser");
+ _controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+
+ // Act
+ var result = await _controller.Logout();
+
+ // Assert
+ result.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void GetCurrentUser_WhenAuthenticated_ReturnsUserInfo()
+ {
+ // Arrange
+ var claims = new List
+ {
+ new(ClaimTypes.Name, "testuser"),
+ new(ClaimTypes.GivenName, "Test"),
+ new(ClaimTypes.Surname, "User"),
+ new(ClaimTypes.Email, "test@example.com"),
+ new("title", "Developer"),
+ new("dn", "CN=testuser,DC=example,DC=com")
+ };
+ var identity = new ClaimsIdentity(claims, "Test");
+ var principal = new ClaimsPrincipal(identity);
+
+ var httpContext = new DefaultHttpContext { User = principal };
+ _controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+
+ // Act
+ var result = _controller.GetCurrentUser();
+
+ // Assert
+ result.Result.ShouldBeOfType();
+ var okResult = (OkObjectResult)result.Result!;
+ var user = okResult.Value.ShouldBeOfType();
+ user.Username.ShouldBe("testuser");
+ }
+
+ private static HttpContext CreateMockHttpContext()
+ {
+ var authServiceMock = Substitute.For();
+ authServiceMock.SignOutAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ authServiceMock.SignInAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var serviceProvider = Substitute.For();
+ serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authServiceMock);
+
+ var httpContext = new DefaultHttpContext
+ {
+ RequestServices = serviceProvider
+ };
+
+ return httpContext;
+ }
+
+ private static HttpContext CreateAuthenticatedHttpContext(string username)
+ {
+ var httpContext = CreateMockHttpContext();
+ var claims = new List
+ {
+ new(ClaimTypes.Name, username),
+ new("dn", $"CN={username},DC=example,DC=com")
+ };
+ var identity = new ClaimsIdentity(claims, "Test");
+ httpContext.User = new ClaimsPrincipal(identity);
+ return httpContext;
+ }
+}
+```
+
+**Step 2: Run tests to verify they pass**
+
+Run: `dotnet test NEW/tests/JdeScoping.Api.Tests --filter "FullyQualifiedName~AuthControllerTests" --verbosity normal`
+Expected: All 6 tests pass
+
+**Step 3: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
+git commit -m "test(api): update AuthController tests for encrypted login"
+```
+
+---
+
+## Task 7: Add Blazor.SubtleCrypto Package to Client
+
+**Files:**
+- Modify: `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+
+**Step 1: Add package reference**
+
+Run: `dotnet add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj package Blazor.SubtleCrypto`
+
+**Step 2: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
+git commit -m "chore(client): add Blazor.SubtleCrypto package"
+```
+
+---
+
+## Task 8: Create ICryptoService and Implementation
+
+**Files:**
+- Create: `NEW/src/JdeScoping.Client/Services/ICryptoService.cs`
+- Create: `NEW/src/JdeScoping.Client/Services/CryptoService.cs`
+
+**Step 1: Create ICryptoService interface**
+
+```csharp
+// NEW/src/JdeScoping.Client/Services/ICryptoService.cs
+using JdeScoping.Core.Models.Auth;
+
+namespace JdeScoping.Client.Services;
+
+///
+/// Service for encrypting data using server's RSA public key.
+///
+public interface ICryptoService
+{
+ ///
+ /// Encrypts login credentials for transmission to server.
+ ///
+ /// Login credentials to encrypt
+ /// Base64-encoded encrypted data
+ Task EncryptLoginAsync(LoginModel model);
+}
+```
+
+**Step 2: Create CryptoService implementation**
+
+```csharp
+// NEW/src/JdeScoping.Client/Services/CryptoService.cs
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Blazor.SubtleCrypto;
+using JdeScoping.Core.Models.Auth;
+
+namespace JdeScoping.Client.Services;
+
+///
+/// Encrypts login credentials using Web Crypto API via Blazor.SubtleCrypto.
+///
+public class CryptoService : ICryptoService
+{
+ private readonly HttpClient _httpClient;
+ private readonly ICryptoService _cryptoProvider;
+ private CryptoKey? _serverPublicKey;
+ private readonly SemaphoreSlim _keyLock = new(1, 1);
+
+ public CryptoService(HttpClient httpClient, ICryptoService cryptoProvider)
+ {
+ _httpClient = httpClient;
+ _cryptoProvider = cryptoProvider;
+ }
+
+ public async Task EncryptLoginAsync(LoginModel model)
+ {
+ var publicKey = await GetOrFetchPublicKeyAsync();
+
+ var json = JsonSerializer.Serialize(model);
+ var plaintext = Encoding.UTF8.GetBytes(json);
+
+ var encrypted = await _cryptoProvider.EncryptAsync(
+ plaintext,
+ publicKey,
+ new EncryptParams { Name = "RSA-OAEP" });
+
+ return Convert.ToBase64String(encrypted);
+ }
+
+ private async Task GetOrFetchPublicKeyAsync()
+ {
+ if (_serverPublicKey is not null)
+ return _serverPublicKey;
+
+ await _keyLock.WaitAsync();
+ try
+ {
+ if (_serverPublicKey is not null)
+ return _serverPublicKey;
+
+ var response = await _httpClient.GetFromJsonAsync("api/auth/public-key")
+ ?? throw new InvalidOperationException("Failed to fetch public key");
+
+ _serverPublicKey = await _cryptoProvider.ImportKeyAsync(
+ "spki",
+ Convert.FromBase64String(ExtractBase64FromPem(response.PublicKeyPem)),
+ new Algorithm { Name = "RSA-OAEP", Hash = "SHA-256" },
+ false,
+ new[] { "encrypt" });
+
+ return _serverPublicKey;
+ }
+ finally
+ {
+ _keyLock.Release();
+ }
+ }
+
+ private static string ExtractBase64FromPem(string pem)
+ {
+ return pem
+ .Replace("-----BEGIN PUBLIC KEY-----", "")
+ .Replace("-----END PUBLIC KEY-----", "")
+ .Replace("\n", "")
+ .Replace("\r", "")
+ .Trim();
+ }
+}
+```
+
+**Step 3: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+Expected: Build succeeded (may have warnings about Blazor.SubtleCrypto API - we'll fix in next step if needed)
+
+**Step 4: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Client/Services/ICryptoService.cs
+git add NEW/src/JdeScoping.Client/Services/CryptoService.cs
+git commit -m "feat(client): add CryptoService for login encryption"
+```
+
+---
+
+## Task 9: Update Client AuthService and IAuthService
+
+**Files:**
+- Modify: `NEW/src/JdeScoping.Client/Services/IAuthService.cs`
+- Modify: `NEW/src/JdeScoping.Client/Services/AuthService.cs`
+- Delete: `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
+
+**Step 1: Update IAuthService to use shared models**
+
+Replace the entire file:
+
+```csharp
+// NEW/src/JdeScoping.Client/Services/IAuthService.cs
+using JdeScoping.Core.Models.Auth;
+
+namespace JdeScoping.Client.Services;
+
+///
+/// Service for authentication operations.
+///
+public interface IAuthService
+{
+ ///
+ /// Attempts to log in with the provided credentials (encrypted).
+ ///
+ Task LoginAsync(LoginModel model);
+
+ ///
+ /// Logs out the current user.
+ ///
+ Task LogoutAsync();
+}
+```
+
+**Step 2: Update AuthService to use encrypted login**
+
+Replace the entire file:
+
+```csharp
+// NEW/src/JdeScoping.Client/Services/AuthService.cs
+using System.Net.Http.Json;
+using JdeScoping.Client.Auth;
+using JdeScoping.Core.Models.Auth;
+
+namespace JdeScoping.Client.Services;
+
+///
+/// Handles authentication via encrypted API calls with cookie-based auth.
+///
+public class AuthService : IAuthService
+{
+ private readonly HttpClient _httpClient;
+ private readonly ICryptoService _cryptoService;
+ private readonly AuthStateProvider _authStateProvider;
+
+ public AuthService(
+ HttpClient httpClient,
+ ICryptoService cryptoService,
+ AuthStateProvider authStateProvider)
+ {
+ _httpClient = httpClient;
+ _cryptoService = cryptoService;
+ _authStateProvider = authStateProvider;
+ }
+
+ public async Task LoginAsync(LoginModel model)
+ {
+ try
+ {
+ // Encrypt credentials
+ var encryptedData = await _cryptoService.EncryptLoginAsync(model);
+ var request = new EncryptedLoginRequest(encryptedData);
+
+ // Send encrypted request
+ var response = await _httpClient.PostAsJsonAsync("api/auth/login", request);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ if (result is null)
+ {
+ return new LoginResultModel(false, "Invalid response from server", null);
+ }
+
+ if (result.Success && result.User is not null)
+ {
+ // Notify auth state provider of the login
+ var userViewModel = new UserInfoViewModel
+ {
+ Username = result.User.Username,
+ FirstName = result.User.FirstName,
+ LastName = result.User.LastName,
+ EmailAddress = result.User.EmailAddress,
+ Title = result.User.Title,
+ Dn = result.User.Dn
+ };
+ await _authStateProvider.MarkUserAsAuthenticated(userViewModel);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ return new LoginResultModel(false, $"Login failed: {ex.Message}", null);
+ }
+ }
+
+ public async Task LogoutAsync()
+ {
+ try
+ {
+ await _httpClient.PostAsync("api/auth/logout", null);
+ }
+ catch
+ {
+ // Even if logout API fails, clear local state
+ }
+
+ await _authStateProvider.LogoutAsync();
+ }
+}
+```
+
+**Step 3: Delete old LoginModel**
+
+Delete file: `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
+
+**Step 4: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+Expected: Build succeeded
+
+**Step 5: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Client/Services/IAuthService.cs
+git add NEW/src/JdeScoping.Client/Services/AuthService.cs
+git rm NEW/src/JdeScoping.Client/Models/LoginModel.cs
+git commit -m "feat(client): update AuthService to use encrypted login"
+```
+
+---
+
+## Task 10: Update Login.razor to Use Shared Model
+
+**Files:**
+- Modify: `NEW/src/JdeScoping.Client/Pages/Login.razor`
+
+**Step 1: Update the using statement and result handling**
+
+Change line 1 and add using at the top:
+
+```razor
+@page "/login"
+@using JdeScoping.Core.Models.Auth
+@inject IAuthService AuthService
+@inject NavigationManager NavigationManager
+```
+
+Update the HandleLoginAsync method (in the @code block) to use LoginResultModel:
+
+```csharp
+ private async Task HandleLoginAsync()
+ {
+ _isLoading = true;
+ _errorMessage = null;
+
+ try
+ {
+ var result = await AuthService.LoginAsync(_loginModel);
+
+ if (result.Success)
+ {
+ var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
+ NavigationManager.NavigateTo(returnUrl);
+ }
+ else
+ {
+ _errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials.";
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"An error occurred: {ex.Message}";
+ }
+ finally
+ {
+ _isLoading = false;
+ }
+ }
+```
+
+**Step 2: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Client/Pages/Login.razor
+git commit -m "feat(client): update Login.razor to use shared LoginModel"
+```
+
+---
+
+## Task 11: Register CryptoService in Client DI
+
+**Files:**
+- Modify: `NEW/src/JdeScoping.Client/Program.cs`
+
+**Step 1: Add CryptoService registration**
+
+Add after line 7 (after existing using statements):
+
+```csharp
+using Blazor.SubtleCrypto;
+```
+
+Add after line 26 (after AuthStateProvider registration, before IAuthService):
+
+```csharp
+// Crypto service for login encryption
+builder.Services.AddSubtleCrypto();
+builder.Services.AddScoped();
+```
+
+Update IAuthService registration (line 28) - it should still work as AuthService now takes ICryptoService.
+
+**Step 2: Verify build succeeds**
+
+Run: `dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add NEW/src/JdeScoping.Client/Program.cs
+git commit -m "chore(client): register CryptoService in DI"
+```
+
+---
+
+## Task 12: Add Client CryptoService Tests
+
+**Files:**
+- Create: `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs`
+- Modify: `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj`
+- Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
+
+**Step 1: Update Client.Tests csproj to add required packages**
+
+Add to ItemGroup with PackageReferences:
+
+```xml
+
+
+```
+
+Also add project reference to Core:
+
+```xml
+
+
+
+
+```
+
+**Step 2: Create CryptoServiceTests**
+
+Note: Testing Blazor.SubtleCrypto directly is challenging because it requires browser APIs. We'll create integration-style tests that verify the service behavior with mocks.
+
+```csharp
+// NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text.Json;
+using JdeScoping.Client.Services;
+using JdeScoping.Core.Models.Auth;
+using NSubstitute;
+using RichardSzalay.MockHttp;
+using Shouldly;
+
+namespace JdeScoping.Client.Tests.Services;
+
+public class CryptoServiceTests
+{
+ [Fact]
+ public async Task EncryptLoginAsync_FetchesPublicKeyOnce()
+ {
+ // Arrange
+ using var rsa = RSA.Create(2048);
+ var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
+ var mockHttp = new MockHttpMessageHandler();
+
+ // Setup public key endpoint - should only be called once
+ var keyRequest = mockHttp.Expect("/api/auth/public-key")
+ .Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
+
+ var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
+
+ // Create a mock crypto provider that just returns dummy encrypted data
+ var cryptoProvider = Substitute.For();
+ cryptoProvider.ImportKeyAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new Blazor.SubtleCrypto.CryptoKey());
+ cryptoProvider.EncryptAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new byte[] { 1, 2, 3, 4 });
+
+ var service = new CryptoService(httpClient, cryptoProvider);
+ var loginModel = new LoginModel { Username = "test", Password = "pass" };
+
+ // Act - call twice
+ await service.EncryptLoginAsync(loginModel);
+ await service.EncryptLoginAsync(loginModel);
+
+ // Assert - public key fetched only once
+ mockHttp.GetMatchCount(keyRequest).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task EncryptLoginAsync_ReturnsBase64String()
+ {
+ // Arrange
+ using var rsa = RSA.Create(2048);
+ var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
+ var mockHttp = new MockHttpMessageHandler();
+ mockHttp.When("/api/auth/public-key")
+ .Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
+
+ var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
+
+ var cryptoProvider = Substitute.For();
+ cryptoProvider.ImportKeyAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new Blazor.SubtleCrypto.CryptoKey());
+ cryptoProvider.EncryptAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new byte[] { 1, 2, 3, 4 });
+
+ var service = new CryptoService(httpClient, cryptoProvider);
+ var loginModel = new LoginModel { Username = "test", Password = "pass" };
+
+ // Act
+ var result = await service.EncryptLoginAsync(loginModel);
+
+ // Assert - should be valid base64
+ var decoded = Convert.FromBase64String(result);
+ decoded.ShouldNotBeEmpty();
+ }
+}
+```
+
+**Step 3: Delete placeholder file**
+
+Delete: `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test NEW/tests/JdeScoping.Client.Tests --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.Client.Tests/
+git rm NEW/tests/JdeScoping.Client.Tests/Placeholder.cs
+git commit -m "test(client): add CryptoService tests"
+```
+
+---
+
+## Task 13: Update Integration Tests
+
+**Files:**
+- Modify: `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs`
+
+**Step 1: Update integration tests for encrypted login**
+
+The integration tests need to use encrypted login requests. Update the test file to encrypt credentials before sending.
+
+Read the current file first to understand its structure, then update to use RSA encryption similar to the unit tests.
+
+**Step 2: Run all tests**
+
+Run: `dotnet test NEW/tests --verbosity normal`
+Expected: All tests pass
+
+**Step 3: Commit**
+
+```bash
+git add NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
+git commit -m "test(integration): update auth tests for encrypted login"
+```
+
+---
+
+## Task 14: Final Verification
+
+**Step 1: Build entire solution**
+
+Run: `dotnet build NEW/JdeScoping.sln`
+Expected: Build succeeded with 0 errors
+
+**Step 2: Run all tests**
+
+Run: `dotnet test NEW/JdeScoping.sln --verbosity normal`
+Expected: All tests pass
+
+**Step 3: Verify deleted files are gone**
+
+Confirm these files no longer exist:
+- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
+- `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
+
+**Step 4: Final commit**
+
+```bash
+git add -A
+git commit -m "feat: complete encrypted login implementation"
+```
+
+---
+
+## Summary
+
+**Files Created:**
+- `NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs`
+- `NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs`
+- `NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs`
+- `NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs`
+- `NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs`
+- `NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs`
+- `NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs`
+- `NEW/src/JdeScoping.Client/Services/ICryptoService.cs`
+- `NEW/src/JdeScoping.Client/Services/CryptoService.cs`
+- `NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs`
+- `NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs`
+
+**Files Modified:**
+- `NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs`
+- `NEW/src/JdeScoping.Api/Controllers/AuthController.cs`
+- `NEW/src/JdeScoping.Client/Services/IAuthService.cs`
+- `NEW/src/JdeScoping.Client/Services/AuthService.cs`
+- `NEW/src/JdeScoping.Client/Pages/Login.razor`
+- `NEW/src/JdeScoping.Client/Program.cs`
+- `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
+- `NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs`
+- `NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj`
+- `NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs`
+
+**Files Deleted:**
+- `NEW/src/JdeScoping.Api/Models/LoginRequest.cs`
+- `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
+- `NEW/tests/JdeScoping.Client.Tests/Placeholder.cs`
diff --git a/PLANS/2026-01-03-etl-pipeline-design.md b/PLANS/2026-01-03-etl-pipeline-design.md
new file mode 100644
index 0000000..90e10f1
--- /dev/null
+++ b/PLANS/2026-01-03-etl-pipeline-design.md
@@ -0,0 +1,334 @@
+# ETL Pipeline Design for DataSync
+
+**Date:** 2026-01-03
+**Status:** Reviewed (Codex MCP)
+**Purpose:** Replace the existing strongly-typed fetcher + source-generated DataReader + BulkMergeHelper system with a flexible, configuration-driven ETL pipeline.
+
+## Problem Statement
+
+The current DataSync system requires:
+1. A source-generated `IDataReader` implementation per entity type
+2. Strongly-typed `IDataFetcher` classes for each data source
+3. Code changes to modify queries or add new sync operations
+
+**Primary drivers for change:**
+- **Flexibility** - Enable ad-hoc queries and schema changes without code modifications
+- **Observability** - Better error handling and row count tracking across pipeline steps
+
+## Design Overview
+
+```
+┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+│ ImportSource│───▶│ Transformer │───▶│ Destination │
+│ (IDataReader) │ (decorates) │ │ (bulk ops) │
+└─────────────┘ └──────────────┘ └─────────────┘
+ ▲ │
+ │ ▼
+┌──────┴──────┐ ┌──────────────┐
+│ Pre-Scripts │ │ Post-Scripts │
+└─────────────┘ └──────────────┘
+```
+
+All steps report to `PipelineResult` with timing and row counts.
+
+## Core Interfaces
+
+### IImportSource
+
+Reads data from a source system, returns `IDataReader` for streaming.
+
+```csharp
+public interface IImportSource : IAsyncDisposable
+{
+ Task ReadDataAsync(CancellationToken cancellationToken = default);
+ string SourceName { get; }
+}
+```
+
+**Implementations:**
+- `DbQuerySource` - Executes SQL against Oracle/Sybase/SQL Server via `IDbConnectionFactory`
+- `FileSource` - Reads CSV files with configurable delimiter/header options
+
+### IDataTransformer
+
+Wraps an `IDataReader` and applies transformations (column rename, drop, type conversion).
+
+```csharp
+public interface IDataTransformer
+{
+ IDataReader Transform(IDataReader source);
+ string TransformerName { get; }
+}
+```
+
+**Implementations:**
+- `JdeDateTransformer` - Merges Julian date + time columns into `DateTime`
+- `ColumnRenameTransformer` - Renames columns
+- `ColumnDropTransformer` - Removes columns from output
+- `ColumnAddTransformer` - Adds computed columns
+
+Transformers are registered by name in `ITransformerRegistry` for config-based hydration.
+
+### IImportDestination
+
+Writes data to a target table using bulk operations.
+
+```csharp
+public interface IImportDestination
+{
+ Task WriteAsync(
+ IDataReader source,
+ CancellationToken cancellationToken = default);
+ string DestinationName { get; }
+}
+
+public record DestinationResult(
+ long RowsProcessed,
+ int BatchCount,
+ TimeSpan Elapsed);
+```
+
+**Implementations:**
+
+#### DbBulkImportDestination (Full Refresh)
+1. Truncate destination table
+2. Bulk copy all data in batches
+3. Per-batch commits
+
+#### DbBulkMergeDestination (Incremental)
+1. Create temp table from destination schema
+2. For each batch:
+ - Bulk copy to temp table
+ - Execute MERGE to destination
+ - Truncate temp table
+3. Drop temp table on completion
+
+**Configuration options:**
+- `tableName` - Target table
+- `matchColumns` - PK columns for MERGE ON clause
+- `updateColumns` - Columns to update (default: all non-match columns)
+- `batchSize` - Rows per batch (default: 10000)
+
+### IScriptRunner
+
+Executes SQL scripts before or after the main pipeline.
+
+```csharp
+public interface IScriptRunner
+{
+ Task ExecuteAsync(CancellationToken cancellationToken = default);
+ string ScriptName { get; }
+}
+```
+
+**Common scripts (factory methods):**
+- `CommonScripts.DisableIndexes(factory, tableName)`
+- `CommonScripts.RebuildIndexes(factory, tableName)`
+- `CommonScripts.UpdateStatistics(factory, tableName)`
+- `CommonScripts.CustomSql(factory, sql, name)`
+
+### Pipeline Results
+
+```csharp
+public record PipelineResult(
+ bool Success,
+ long TotalRows,
+ TimeSpan Elapsed,
+ IReadOnlyList Steps,
+ Exception? Error = null);
+
+public record StepResult(
+ string StepName,
+ string StepType, // "Source", "Transform", "Destination", "Script"
+ long RowsAffected,
+ TimeSpan Elapsed);
+```
+
+## EtlPipeline Orchestration
+
+```csharp
+public class EtlPipeline
+{
+ public string PipelineName { get; }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ // 1. Run pre-scripts (fail-fast)
+ // 2. Open source, get IDataReader
+ // 3. Chain transformers (decorators)
+ // 4. Write to destination
+ // 5. Run post-scripts
+ // 6. Return PipelineResult with all step metrics
+ }
+}
+```
+
+**Error handling:**
+- Fail-fast on any error
+- No cross-batch transactions (per-batch commits)
+- Caller marks overall sync operation as failed
+- Restart from beginning on failure (no resume)
+
+## Pipeline Construction
+
+### Fluent Builder
+
+```csharp
+var pipeline = new EtlPipelineBuilder()
+ .WithName("WorkOrderSync")
+ .WithSource(new DbQuerySource(connFactory, "JDE", workOrderQuery))
+ .WithTransformer(new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt"))
+ .WithDestination(new DbBulkMergeDestination(connFactory, "WorkOrder", ["OrderNumber"]))
+ .WithPreScript(CommonScripts.DisableIndexes(connFactory, "WorkOrder"))
+ .WithPostScript(CommonScripts.RebuildIndexes(connFactory, "WorkOrder"))
+ .WithLogger(logger)
+ .Build();
+
+var result = await pipeline.ExecuteAsync(cancellationToken);
+```
+
+### Configuration-Based
+
+```json
+{
+ "name": "WorkOrderSync",
+ "source": {
+ "type": "db",
+ "connectionName": "JDE",
+ "query": "SELECT * FROM F4801 WHERE UPMJ >= :minDate"
+ },
+ "transformers": [
+ { "type": "jde-date", "options": { "dateColumn": "UPMJ", "timeColumn": "TDAY", "outputColumn": "UpdatedAt" } }
+ ],
+ "destination": {
+ "type": "bulk-merge",
+ "tableName": "WorkOrder",
+ "matchColumns": ["OrderNumber"],
+ "batchSize": 10000
+ },
+ "preScripts": [
+ { "type": "disable-indexes", "tableName": "WorkOrder" }
+ ],
+ "postScripts": [
+ { "type": "rebuild-indexes", "tableName": "WorkOrder" }
+ ]
+}
+```
+
+Hydrated via `EtlPipelineFactory.CreateFromConfig(config)`.
+
+## Project Structure
+
+```
+NEW/src/JdeScoping.DataSync/
+├── Contracts/
+│ ├── IImportSource.cs
+│ ├── IDataTransformer.cs
+│ ├── IImportDestination.cs
+│ ├── IScriptRunner.cs
+│ └── ITransformerRegistry.cs
+├── Etl/
+│ ├── Pipeline/
+│ │ ├── EtlPipeline.cs
+│ │ ├── EtlPipelineBuilder.cs
+│ │ └── EtlPipelineFactory.cs
+│ ├── Sources/
+│ │ ├── DbQuerySource.cs
+│ │ └── FileSource.cs
+│ ├── Transformers/
+│ │ ├── DataTransformerBase.cs
+│ │ ├── TransformingDataReader.cs
+│ │ ├── JdeDateTransformer.cs
+│ │ ├── ColumnRenameTransformer.cs
+│ │ ├── ColumnDropTransformer.cs
+│ │ └── TransformerRegistry.cs
+│ ├── Destinations/
+│ │ ├── DbBulkImportDestination.cs
+│ │ └── DbBulkMergeDestination.cs
+│ ├── Scripts/
+│ │ ├── SqlScriptRunner.cs
+│ │ └── CommonScripts.cs
+│ └── Results/
+│ ├── PipelineResult.cs
+│ ├── StepResult.cs
+│ └── DestinationResult.cs
+└── Config/
+ └── PipelineConfig.cs
+```
+
+## Integration Strategy
+
+1. **Parallel existence** - New ETL pipeline lives alongside existing `IDataFetcher` + `IBulkMergeHelper`
+2. **Gradual migration** - Convert one sync operation at a time to new pipeline
+3. **Shared infrastructure** - Both use `IDbConnectionFactory`, logging, etc.
+4. **Deprecation path** - Once all syncs migrated, remove old `Fetchers/` and source generator
+
+## DI Registration
+
+```csharp
+public static class EtlServiceCollectionExtensions
+{
+ public static IServiceCollection AddEtlPipeline(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddTransient();
+ return services;
+ }
+}
+```
+
+## Design Decisions
+
+Based on Codex MCP review, the following decisions were made:
+
+### Atomicity & Consistency
+
+| Concern | Decision | Rationale |
+|---------|----------|-----------|
+| Full refresh partial visibility | **Accept** | Caller retries on failure; consumers tolerate brief inconsistency |
+| Orphaned disabled indexes | **Accept risk** | Next successful run rebuilds; manual intervention rare |
+| Temp table connection lifetime | **Destination owns connection** | `DbBulkMergeDestination` holds single connection for all batches |
+
+### Implementation Requirements
+
+1. **TransformingDataReader schema accuracy** - Transformers MUST correctly implement:
+ - `GetSchemaTable()` - Accurate after column changes
+ - `GetName(ordinal)` - Reflects renames
+ - `GetOrdinal(name)` - Works with new names
+ - `GetFieldType(ordinal)` - Correct after type transforms
+ - `FieldCount` - Accurate after drops/adds
+
+2. **Transform step row counts** - `StepResult.RowsAffected` is `0` for transform steps (inline decorators). Only destination reports actual rows processed.
+
+3. **Cancellation handling** - Destinations use `DbDataReader.ReadAsync()` internally, checking cancellation between rows. Source `IDataReader` interface stays synchronous for ADO.NET compatibility.
+
+4. **Schema validation** - Optional `ValidateSchema` step available to compare source columns against expected schema before pipeline runs. Not mandatory.
+
+5. **MERGE duplicate keys** - Source data MUST have unique keys for match columns. Pipeline does not dedupe; caller's responsibility to ensure uniqueness.
+
+### Connection Lifetime Rules
+
+```
+DbQuerySource:
+ - Opens connection in ReadDataAsync()
+ - Holds connection until DisposeAsync()
+ - Connection closed when pipeline completes or fails
+
+DbBulkImportDestination:
+ - Opens connection in WriteAsync()
+ - Holds for all batches
+ - Closes on completion or failure
+
+DbBulkMergeDestination:
+ - Opens connection in WriteAsync()
+ - Creates #temp table
+ - Holds same connection for all batch cycles
+ - Drops temp table in finally block
+ - Closes connection on completion or failure
+```
+
+## Open Questions
+
+1. Should `DbQuerySource` support parameterized queries with different parameter styles (`:param` for Oracle, `@param` for SQL Server)?
+2. Should we support multiple destinations per pipeline (e.g., write to both current and history tables)?
+3. How should pipeline configurations be stored and loaded (appsettings.json, separate files, database)?
diff --git a/PLANS/2026-01-03-etl-pipeline-implementation.md b/PLANS/2026-01-03-etl-pipeline-implementation.md
new file mode 100644
index 0000000..ad3bea7
--- /dev/null
+++ b/PLANS/2026-01-03-etl-pipeline-implementation.md
@@ -0,0 +1,2511 @@
+# ETL Pipeline Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Implement a flexible, configuration-driven ETL pipeline for DataSync that replaces the strongly-typed fetcher + source-generated DataReader pattern.
+
+**Architecture:** Pipeline components (Source → Transformer → Destination) connected via `IDataReader`. Transformers are decorators that wrap readers. Destinations own their connections. Pipeline orchestrates with fail-fast error handling and step-by-step metrics.
+
+**Tech Stack:** .NET 10, Microsoft.Data.SqlClient, xUnit, NSubstitute
+
+---
+
+## Phase 1: Core Interfaces and Result Models
+
+### Task 1: Create Result Models
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Results/DestinationResult.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Results/StepResult.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Results/PipelineResult.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Results/PipelineResultTests.cs`
+
+**Step 1: Create DestinationResult record**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Results/DestinationResult.cs
+namespace JdeScoping.DataSync.Etl.Results;
+
+///
+/// Result from a destination write operation.
+///
+public record DestinationResult(
+ long RowsProcessed,
+ int BatchCount,
+ TimeSpan Elapsed);
+```
+
+**Step 2: Create StepResult record**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Results/StepResult.cs
+namespace JdeScoping.DataSync.Etl.Results;
+
+///
+/// Result from a single pipeline step.
+///
+public record StepResult(
+ string StepName,
+ string StepType,
+ long RowsAffected,
+ TimeSpan Elapsed);
+```
+
+**Step 3: Create PipelineResult record**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Results/PipelineResult.cs
+namespace JdeScoping.DataSync.Etl.Results;
+
+///
+/// Complete result from pipeline execution.
+///
+public record PipelineResult(
+ bool Success,
+ long TotalRows,
+ TimeSpan Elapsed,
+ IReadOnlyList Steps,
+ Exception? Error = null)
+{
+ ///
+ /// Creates a successful result.
+ ///
+ public static PipelineResult Succeeded(long totalRows, TimeSpan elapsed, IReadOnlyList steps)
+ => new(true, totalRows, elapsed, steps);
+
+ ///
+ /// Creates a failed result.
+ ///
+ public static PipelineResult Failed(long totalRows, TimeSpan elapsed, IReadOnlyList steps, Exception error)
+ => new(false, totalRows, elapsed, steps, error);
+}
+```
+
+**Step 4: Write test for PipelineResult factory methods**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Results/PipelineResultTests.cs
+namespace JdeScoping.DataSync.Tests.Etl.Results;
+
+using JdeScoping.DataSync.Etl.Results;
+
+public class PipelineResultTests
+{
+ [Fact]
+ public void Succeeded_CreatesSuccessfulResult()
+ {
+ var steps = new List
+ {
+ new("Source", "Source", 0, TimeSpan.FromSeconds(1)),
+ new("Destination", "Destination", 100, TimeSpan.FromSeconds(5))
+ };
+
+ var result = PipelineResult.Succeeded(100, TimeSpan.FromSeconds(6), steps);
+
+ Assert.True(result.Success);
+ Assert.Equal(100, result.TotalRows);
+ Assert.Null(result.Error);
+ Assert.Equal(2, result.Steps.Count);
+ }
+
+ [Fact]
+ public void Failed_CreatesFailedResult()
+ {
+ var steps = new List();
+ var error = new InvalidOperationException("Test error");
+
+ var result = PipelineResult.Failed(0, TimeSpan.FromSeconds(1), steps, error);
+
+ Assert.False(result.Success);
+ Assert.Equal(0, result.TotalRows);
+ Assert.Same(error, result.Error);
+ }
+}
+```
+
+**Step 5: Run tests**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~PipelineResultTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 6: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Results/ tests/JdeScoping.DataSync.Tests/Etl/Results/
+git commit -m "feat(etl): add result models for pipeline execution"
+```
+
+---
+
+### Task 2: Create Core Interfaces
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Contracts/IImportSource.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Contracts/IImportDestination.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Contracts/IScriptRunner.cs`
+
+**Step 1: Create IImportSource interface**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Contracts/IImportSource.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Contracts;
+
+///
+/// Source that reads data and returns an IDataReader for streaming.
+///
+public interface IImportSource : IAsyncDisposable
+{
+ ///
+ /// Opens the source and returns a data reader for streaming rows.
+ ///
+ Task ReadDataAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Name of this source for logging and metrics.
+ ///
+ string SourceName { get; }
+}
+```
+
+**Step 2: Create IDataTransformer interface**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Contracts/IDataTransformer.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Contracts;
+
+///
+/// Transforms an IDataReader by wrapping it with transformation logic.
+///
+public interface IDataTransformer
+{
+ ///
+ /// Wraps the source reader with transformation logic.
+ ///
+ IDataReader Transform(IDataReader source);
+
+ ///
+ /// Name of this transformer for logging and metrics.
+ ///
+ string TransformerName { get; }
+}
+```
+
+**Step 3: Create IImportDestination interface**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Contracts/IImportDestination.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Results;
+
+namespace JdeScoping.DataSync.Etl.Contracts;
+
+///
+/// Destination that writes data from an IDataReader.
+///
+public interface IImportDestination
+{
+ ///
+ /// Writes all rows from the reader to the destination.
+ ///
+ Task WriteAsync(
+ IDataReader source,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Name of this destination for logging and metrics.
+ ///
+ string DestinationName { get; }
+}
+```
+
+**Step 4: Create IScriptRunner interface**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Contracts/IScriptRunner.cs
+namespace JdeScoping.DataSync.Etl.Contracts;
+
+///
+/// Runs a SQL script as part of pipeline pre/post processing.
+///
+public interface IScriptRunner
+{
+ ///
+ /// Executes the script.
+ ///
+ Task ExecuteAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Name of this script for logging and metrics.
+ ///
+ string ScriptName { get; }
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Contracts/
+git commit -m "feat(etl): add core ETL pipeline interfaces"
+```
+
+---
+
+## Phase 2: Script Runner Implementation
+
+### Task 3: Implement SqlScriptRunner
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs`
+
+**Step 1: Write failing test for SqlScriptRunner**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Scripts/SqlScriptRunnerTests.cs
+using System.Data.Common;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Scripts;
+using Microsoft.Data.SqlClient;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Scripts;
+
+public class SqlScriptRunnerTests
+{
+ [Fact]
+ public void Constructor_SetsScriptName()
+ {
+ var factory = Substitute.For();
+
+ var runner = new SqlScriptRunner(factory, "SELECT 1", "TestScript");
+
+ Assert.Equal("TestScript", runner.ScriptName);
+ }
+
+ [Fact]
+ public void Constructor_WithNullName_DefaultsToSqlScript()
+ {
+ var factory = Substitute.For();
+
+ var runner = new SqlScriptRunner(factory, "SELECT 1");
+
+ Assert.Equal("SqlScript", runner.ScriptName);
+ }
+
+ [Fact]
+ public void Constructor_NullFactory_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() =>
+ new SqlScriptRunner(null!, "SELECT 1"));
+ }
+
+ [Fact]
+ public void Constructor_NullSql_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new SqlScriptRunner(factory, null!));
+ }
+
+ [Fact]
+ public void Constructor_EmptySql_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new SqlScriptRunner(factory, ""));
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~SqlScriptRunnerTests" --verbosity normal`
+Expected: FAIL - SqlScriptRunner class does not exist
+
+**Step 3: Implement SqlScriptRunner**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs
+using System.Data;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Contracts;
+
+namespace JdeScoping.DataSync.Etl.Scripts;
+
+///
+/// Runs a SQL script against the database.
+///
+public class SqlScriptRunner : IScriptRunner
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly string _sql;
+ private readonly int _timeoutSeconds;
+
+ public string ScriptName { get; }
+
+ public SqlScriptRunner(
+ IDbConnectionFactory connectionFactory,
+ string sql,
+ string? name = null,
+ int timeoutSeconds = 3600)
+ {
+ ArgumentNullException.ThrowIfNull(connectionFactory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sql);
+
+ _connectionFactory = connectionFactory;
+ _sql = sql;
+ _timeoutSeconds = timeoutSeconds;
+ ScriptName = name ?? "SqlScript";
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken);
+ await using var command = connection.CreateCommand();
+ command.CommandText = _sql;
+ command.CommandTimeout = _timeoutSeconds;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~SqlScriptRunnerTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Scripts/SqlScriptRunner.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/
+git commit -m "feat(etl): implement SqlScriptRunner"
+```
+
+---
+
+### Task 4: Implement CommonScripts Factory
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs`
+
+**Step 1: Write failing test for CommonScripts**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Scripts;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Scripts;
+
+public class CommonScriptsTests
+{
+ private readonly IDbConnectionFactory _factory = Substitute.For();
+
+ [Fact]
+ public void DisableIndexes_ReturnsRunnerWithCorrectName()
+ {
+ var runner = CommonScripts.DisableIndexes(_factory, "WorkOrder");
+
+ Assert.Equal("DisableIndexes:WorkOrder", runner.ScriptName);
+ }
+
+ [Fact]
+ public void RebuildIndexes_ReturnsRunnerWithCorrectName()
+ {
+ var runner = CommonScripts.RebuildIndexes(_factory, "WorkOrder");
+
+ Assert.Equal("RebuildIndexes:WorkOrder", runner.ScriptName);
+ }
+
+ [Fact]
+ public void UpdateStatistics_ReturnsRunnerWithCorrectName()
+ {
+ var runner = CommonScripts.UpdateStatistics(_factory, "WorkOrder");
+
+ Assert.Equal("UpdateStats:WorkOrder", runner.ScriptName);
+ }
+
+ [Fact]
+ public void CustomSql_ReturnsRunnerWithProvidedName()
+ {
+ var runner = CommonScripts.CustomSql(_factory, "SELECT 1", "MyCustomScript");
+
+ Assert.Equal("MyCustomScript", runner.ScriptName);
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~CommonScriptsTests" --verbosity normal`
+Expected: FAIL - CommonScripts class does not exist
+
+**Step 3: Implement CommonScripts**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Contracts;
+
+namespace JdeScoping.DataSync.Etl.Scripts;
+
+///
+/// Factory methods for common SQL scripts used in ETL pipelines.
+///
+public static class CommonScripts
+{
+ ///
+ /// Creates a script that disables non-clustered indexes on a table.
+ ///
+ public static IScriptRunner DisableIndexes(IDbConnectionFactory factory, string tableName)
+ {
+ var sql = $@"
+DECLARE @sql NVARCHAR(MAX) = '';
+SELECT @sql = @sql + 'ALTER INDEX [' + i.name + '] ON [{tableName}] DISABLE;' + CHAR(13)
+FROM sys.indexes i
+INNER JOIN sys.tables t ON i.object_id = t.object_id
+WHERE t.name = '{tableName}'
+ AND i.type = 2
+ AND i.is_disabled = 0;
+IF LEN(@sql) > 0 EXEC sp_executesql @sql;";
+
+ return new SqlScriptRunner(factory, sql, $"DisableIndexes:{tableName}", timeoutSeconds: 300);
+ }
+
+ ///
+ /// Creates a script that rebuilds all indexes on a table.
+ ///
+ public static IScriptRunner RebuildIndexes(IDbConnectionFactory factory, string tableName)
+ {
+ var sql = $"ALTER INDEX ALL ON [{tableName}] REBUILD WITH (FILLFACTOR = 95)";
+ return new SqlScriptRunner(factory, sql, $"RebuildIndexes:{tableName}", timeoutSeconds: 3600);
+ }
+
+ ///
+ /// Creates a script that updates statistics on a table.
+ ///
+ public static IScriptRunner UpdateStatistics(IDbConnectionFactory factory, string tableName)
+ {
+ var sql = $"UPDATE STATISTICS [{tableName}]";
+ return new SqlScriptRunner(factory, sql, $"UpdateStats:{tableName}", timeoutSeconds: 600);
+ }
+
+ ///
+ /// Creates a script runner for custom SQL.
+ ///
+ public static IScriptRunner CustomSql(IDbConnectionFactory factory, string sql, string name)
+ {
+ return new SqlScriptRunner(factory, sql, name);
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~CommonScriptsTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Scripts/CommonScripts.cs tests/JdeScoping.DataSync.Tests/Etl/Scripts/CommonScriptsTests.cs
+git commit -m "feat(etl): add CommonScripts factory for index and statistics scripts"
+```
+
+---
+
+## Phase 3: Transformer Infrastructure
+
+### Task 5: Implement TransformingDataReader Base
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs`
+- Create: `src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs`
+
+**Step 1: Write failing test for TransformingDataReader**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Transformers/TransformingDataReaderTests.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Transformers;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Transformers;
+
+public class TransformingDataReaderTests
+{
+ [Fact]
+ public void Read_DelegatesToSourceReader()
+ {
+ var source = Substitute.For();
+ source.Read().Returns(true, true, false);
+
+ var transformer = new PassThroughTransformer();
+ var reader = transformer.Transform(source);
+
+ Assert.True(reader.Read());
+ Assert.True(reader.Read());
+ Assert.False(reader.Read());
+ }
+
+ [Fact]
+ public void FieldCount_DelegatesToTransformer()
+ {
+ var source = Substitute.For();
+ source.FieldCount.Returns(5);
+
+ var transformer = new PassThroughTransformer();
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(5, reader.FieldCount);
+ }
+
+ [Fact]
+ public void GetName_DelegatesToTransformer()
+ {
+ var source = Substitute.For();
+ source.GetName(0).Returns("OriginalName");
+
+ var transformer = new PassThroughTransformer();
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("OriginalName", reader.GetName(0));
+ }
+
+ [Fact]
+ public void GetValue_DelegatesToTransformer()
+ {
+ var source = Substitute.For();
+ source.GetValue(0).Returns("TestValue");
+
+ var transformer = new PassThroughTransformer();
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("TestValue", reader.GetValue(0));
+ }
+
+ [Fact]
+ public void Dispose_DisposesSourceReader()
+ {
+ var source = Substitute.For();
+
+ var transformer = new PassThroughTransformer();
+ var reader = transformer.Transform(source);
+ reader.Dispose();
+
+ source.Received(1).Dispose();
+ }
+
+ // Test helper - pass-through transformer
+ private class PassThroughTransformer : DataTransformerBase
+ {
+ public override string TransformerName => "PassThrough";
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~TransformingDataReaderTests" --verbosity normal`
+Expected: FAIL - classes do not exist
+
+**Step 3: Implement DataTransformerBase**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Transformers/DataTransformerBase.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Contracts;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// Base class for data transformers that wrap an IDataReader.
+/// Override virtual methods to customize transformation behavior.
+///
+public abstract class DataTransformerBase : IDataTransformer
+{
+ public abstract string TransformerName { get; }
+
+ public IDataReader Transform(IDataReader source)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ OnInitialize(source);
+ return new TransformingDataReader(source, this);
+ }
+
+ ///
+ /// Called once when transform is initialized. Use to cache ordinals.
+ ///
+ protected virtual void OnInitialize(IDataReader source) { }
+
+ ///
+ /// Returns the number of fields after transformation.
+ ///
+ public virtual int GetFieldCount(IDataReader source) => source.FieldCount;
+
+ ///
+ /// Returns the field name at the given ordinal after transformation.
+ ///
+ public virtual string GetName(int ordinal, IDataReader source) => source.GetName(ordinal);
+
+ ///
+ /// Returns the field type at the given ordinal after transformation.
+ ///
+ public virtual Type GetFieldType(int ordinal, IDataReader source) => source.GetFieldType(ordinal);
+
+ ///
+ /// Returns the value at the given ordinal after transformation.
+ ///
+ public virtual object GetValue(int ordinal, IDataReader source) => source.GetValue(ordinal);
+
+ ///
+ /// Returns the ordinal for a field name after transformation.
+ ///
+ public virtual int GetOrdinal(string name, IDataReader source) => source.GetOrdinal(name);
+
+ ///
+ /// Returns whether the value at the given ordinal is null.
+ ///
+ public virtual bool IsDBNull(int ordinal, IDataReader source) => source.IsDBNull(ordinal);
+}
+```
+
+**Step 4: Implement TransformingDataReader**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Transformers/TransformingDataReader.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// IDataReader wrapper that delegates to a transformer for value/schema operations.
+///
+internal sealed class TransformingDataReader : IDataReader
+{
+ private readonly IDataReader _source;
+ private readonly DataTransformerBase _transformer;
+
+ public TransformingDataReader(IDataReader source, DataTransformerBase transformer)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ _transformer = transformer ?? throw new ArgumentNullException(nameof(transformer));
+ }
+
+ // Schema properties - delegate to transformer
+ public int FieldCount => _transformer.GetFieldCount(_source);
+ public string GetName(int i) => _transformer.GetName(i, _source);
+ public Type GetFieldType(int i) => _transformer.GetFieldType(i, _source);
+ public int GetOrdinal(string name) => _transformer.GetOrdinal(name, _source);
+
+ // Value access - delegate to transformer
+ public object GetValue(int i) => _transformer.GetValue(i, _source);
+ public bool IsDBNull(int i) => _transformer.IsDBNull(i, _source);
+ public object this[int i] => GetValue(i);
+ public object this[string name] => GetValue(GetOrdinal(name));
+
+ // Row navigation - delegate to source
+ public bool Read() => _source.Read();
+ public bool NextResult() => _source.NextResult();
+ public int Depth => _source.Depth;
+ public bool IsClosed => _source.IsClosed;
+ public int RecordsAffected => _source.RecordsAffected;
+ public void Close() => _source.Close();
+ public void Dispose() => _source.Dispose();
+
+ // Type-specific getters - delegate to transformer via GetValue
+ public bool GetBoolean(int i) => (bool)GetValue(i);
+ public byte GetByte(int i) => (byte)GetValue(i);
+ public char GetChar(int i) => (char)GetValue(i);
+ public DateTime GetDateTime(int i) => (DateTime)GetValue(i);
+ public decimal GetDecimal(int i) => (decimal)GetValue(i);
+ public double GetDouble(int i) => (double)GetValue(i);
+ public float GetFloat(int i) => (float)GetValue(i);
+ public Guid GetGuid(int i) => (Guid)GetValue(i);
+ public short GetInt16(int i) => (short)GetValue(i);
+ public int GetInt32(int i) => (int)GetValue(i);
+ public long GetInt64(int i) => (long)GetValue(i);
+ public string GetString(int i) => (string)GetValue(i);
+
+ public string GetDataTypeName(int i) => _source.GetDataTypeName(i);
+ public int GetValues(object[] values)
+ {
+ var count = Math.Min(values.Length, FieldCount);
+ for (int i = 0; i < count; i++)
+ values[i] = GetValue(i);
+ return count;
+ }
+
+ public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length)
+ => _source.GetBytes(i, fieldOffset, buffer, bufferoffset, length);
+
+ public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length)
+ => _source.GetChars(i, fieldoffset, buffer, bufferoffset, length);
+
+ public IDataReader GetData(int i) => _source.GetData(i);
+
+ public DataTable? GetSchemaTable() => _source.GetSchemaTable();
+}
+```
+
+**Step 5: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~TransformingDataReaderTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 6: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Transformers/ tests/JdeScoping.DataSync.Tests/Etl/Transformers/
+git commit -m "feat(etl): implement TransformingDataReader and DataTransformerBase"
+```
+
+---
+
+### Task 6: Implement ColumnDropTransformer
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Transformers;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Transformers;
+
+public class ColumnDropTransformerTests
+{
+ [Fact]
+ public void FieldCount_ExcludesDroppedColumns()
+ {
+ var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" });
+
+ var transformer = new ColumnDropTransformer("DropMe");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(3, reader.FieldCount);
+ }
+
+ [Fact]
+ public void GetName_SkipsDroppedColumns()
+ {
+ var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" });
+
+ var transformer = new ColumnDropTransformer("DropMe");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("Id", reader.GetName(0));
+ Assert.Equal("Name", reader.GetName(1));
+ Assert.Equal("Value", reader.GetName(2));
+ }
+
+ [Fact]
+ public void GetOrdinal_ReturnsRemappedOrdinal()
+ {
+ var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" });
+
+ var transformer = new ColumnDropTransformer("DropMe");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(0, reader.GetOrdinal("Id"));
+ Assert.Equal(1, reader.GetOrdinal("Name"));
+ Assert.Equal(2, reader.GetOrdinal("Value"));
+ }
+
+ [Fact]
+ public void GetOrdinal_DroppedColumn_ThrowsIndexOutOfRange()
+ {
+ var source = CreateMockReader(new[] { "Id", "Name", "DropMe", "Value" });
+
+ var transformer = new ColumnDropTransformer("DropMe");
+ var reader = transformer.Transform(source);
+
+ Assert.Throws(() => reader.GetOrdinal("DropMe"));
+ }
+
+ [Fact]
+ public void GetValue_ReturnsCorrectValues()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "Name", "DropMe", "Value" },
+ new object[] { 1, "Test", "Dropped", 42 });
+
+ var transformer = new ColumnDropTransformer("DropMe");
+ var reader = transformer.Transform(source);
+ source.Read().Returns(true);
+ reader.Read();
+
+ Assert.Equal(1, reader.GetValue(0));
+ Assert.Equal("Test", reader.GetValue(1));
+ Assert.Equal(42, reader.GetValue(2));
+ }
+
+ [Fact]
+ public void MultipleDroppedColumns_AllExcluded()
+ {
+ var source = CreateMockReader(new[] { "Id", "Drop1", "Name", "Drop2", "Value" });
+
+ var transformer = new ColumnDropTransformer("Drop1", "Drop2");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(3, reader.FieldCount);
+ Assert.Equal("Id", reader.GetName(0));
+ Assert.Equal("Name", reader.GetName(1));
+ Assert.Equal("Value", reader.GetName(2));
+ }
+
+ private static IDataReader CreateMockReader(string[] columns, object[]? values = null)
+ {
+ var reader = Substitute.For();
+ reader.FieldCount.Returns(columns.Length);
+
+ for (int i = 0; i < columns.Length; i++)
+ {
+ var index = i;
+ reader.GetName(index).Returns(columns[index]);
+ reader.GetOrdinal(columns[index]).Returns(index);
+ if (values != null)
+ reader.GetValue(index).Returns(values[index]);
+ }
+
+ return reader;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnDropTransformerTests" --verbosity normal`
+Expected: FAIL - ColumnDropTransformer does not exist
+
+**Step 3: Implement ColumnDropTransformer**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// Transformer that removes columns from the output.
+///
+public class ColumnDropTransformer : DataTransformerBase
+{
+ private readonly HashSet _columnsToDrop;
+ private int[]? _ordinalMap; // maps output ordinal -> source ordinal
+ private Dictionary? _nameToOrdinal;
+
+ public override string TransformerName => $"DropColumns:{string.Join(",", _columnsToDrop)}";
+
+ public ColumnDropTransformer(params string[] columnsToDrop)
+ {
+ ArgumentNullException.ThrowIfNull(columnsToDrop);
+ _columnsToDrop = new HashSet(columnsToDrop, StringComparer.OrdinalIgnoreCase);
+ }
+
+ protected override void OnInitialize(IDataReader source)
+ {
+ var ordinalList = new List();
+ _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ for (int i = 0; i < source.FieldCount; i++)
+ {
+ var name = source.GetName(i);
+ if (!_columnsToDrop.Contains(name))
+ {
+ _nameToOrdinal[name] = ordinalList.Count;
+ ordinalList.Add(i);
+ }
+ }
+
+ _ordinalMap = ordinalList.ToArray();
+ }
+
+ public override int GetFieldCount(IDataReader source) => _ordinalMap!.Length;
+
+ public override string GetName(int ordinal, IDataReader source)
+ => source.GetName(_ordinalMap![ordinal]);
+
+ public override Type GetFieldType(int ordinal, IDataReader source)
+ => source.GetFieldType(_ordinalMap![ordinal]);
+
+ public override object GetValue(int ordinal, IDataReader source)
+ => source.GetValue(_ordinalMap![ordinal]);
+
+ public override int GetOrdinal(string name, IDataReader source)
+ {
+ if (_nameToOrdinal!.TryGetValue(name, out var ordinal))
+ return ordinal;
+ throw new IndexOutOfRangeException($"Column '{name}' not found or was dropped.");
+ }
+
+ public override bool IsDBNull(int ordinal, IDataReader source)
+ => source.IsDBNull(_ordinalMap![ordinal]);
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnDropTransformerTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Transformers/ColumnDropTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnDropTransformerTests.cs
+git commit -m "feat(etl): implement ColumnDropTransformer"
+```
+
+---
+
+### Task 7: Implement ColumnRenameTransformer
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Transformers;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Transformers;
+
+public class ColumnRenameTransformerTests
+{
+ [Fact]
+ public void GetName_ReturnsRenamedColumn()
+ {
+ var source = CreateMockReader(new[] { "OldName", "Other" });
+
+ var transformer = new ColumnRenameTransformer(("OldName", "NewName"));
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("NewName", reader.GetName(0));
+ Assert.Equal("Other", reader.GetName(1));
+ }
+
+ [Fact]
+ public void GetOrdinal_FindsByNewName()
+ {
+ var source = CreateMockReader(new[] { "OldName", "Other" });
+
+ var transformer = new ColumnRenameTransformer(("OldName", "NewName"));
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(0, reader.GetOrdinal("NewName"));
+ }
+
+ [Fact]
+ public void GetOrdinal_OldName_ThrowsIndexOutOfRange()
+ {
+ var source = CreateMockReader(new[] { "OldName", "Other" });
+
+ var transformer = new ColumnRenameTransformer(("OldName", "NewName"));
+ var reader = transformer.Transform(source);
+
+ Assert.Throws(() => reader.GetOrdinal("OldName"));
+ }
+
+ [Fact]
+ public void FieldCount_Unchanged()
+ {
+ var source = CreateMockReader(new[] { "OldName", "Other" });
+
+ var transformer = new ColumnRenameTransformer(("OldName", "NewName"));
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(2, reader.FieldCount);
+ }
+
+ [Fact]
+ public void MultipleRenames_AllApplied()
+ {
+ var source = CreateMockReader(new[] { "A", "B", "C" });
+
+ var transformer = new ColumnRenameTransformer(("A", "Alpha"), ("C", "Charlie"));
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("Alpha", reader.GetName(0));
+ Assert.Equal("B", reader.GetName(1));
+ Assert.Equal("Charlie", reader.GetName(2));
+ }
+
+ private static IDataReader CreateMockReader(string[] columns)
+ {
+ var reader = Substitute.For();
+ reader.FieldCount.Returns(columns.Length);
+
+ for (int i = 0; i < columns.Length; i++)
+ {
+ var index = i;
+ reader.GetName(index).Returns(columns[index]);
+ reader.GetOrdinal(columns[index]).Returns(index);
+ }
+
+ return reader;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnRenameTransformerTests" --verbosity normal`
+Expected: FAIL - ColumnRenameTransformer does not exist
+
+**Step 3: Implement ColumnRenameTransformer**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// Transformer that renames columns in the output.
+///
+public class ColumnRenameTransformer : DataTransformerBase
+{
+ private readonly Dictionary _renames; // old -> new
+ private string[]? _outputNames;
+ private Dictionary? _nameToOrdinal;
+
+ public override string TransformerName => $"RenameColumns:{_renames.Count}";
+
+ public ColumnRenameTransformer(params (string OldName, string NewName)[] renames)
+ {
+ ArgumentNullException.ThrowIfNull(renames);
+ _renames = renames.ToDictionary(
+ r => r.OldName,
+ r => r.NewName,
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ protected override void OnInitialize(IDataReader source)
+ {
+ _outputNames = new string[source.FieldCount];
+ _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ for (int i = 0; i < source.FieldCount; i++)
+ {
+ var originalName = source.GetName(i);
+ var outputName = _renames.TryGetValue(originalName, out var newName)
+ ? newName
+ : originalName;
+ _outputNames[i] = outputName;
+ _nameToOrdinal[outputName] = i;
+ }
+ }
+
+ public override string GetName(int ordinal, IDataReader source)
+ => _outputNames![ordinal];
+
+ public override int GetOrdinal(string name, IDataReader source)
+ {
+ if (_nameToOrdinal!.TryGetValue(name, out var ordinal))
+ return ordinal;
+ throw new IndexOutOfRangeException($"Column '{name}' not found.");
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~ColumnRenameTransformerTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Transformers/ColumnRenameTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/ColumnRenameTransformerTests.cs
+git commit -m "feat(etl): implement ColumnRenameTransformer"
+```
+
+---
+
+### Task 8: Implement JdeDateTransformer
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Transformers;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Transformers;
+
+public class JdeDateTransformerTests
+{
+ [Fact]
+ public void FieldCount_ReducedByOne()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, 124001m, 120000m, "Test" });
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(3, reader.FieldCount);
+ }
+
+ [Fact]
+ public void GetName_DateColumnRenamed_TimeColumnRemoved()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, 124001m, 120000m, "Test" });
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal("Id", reader.GetName(0));
+ Assert.Equal("UpdatedAt", reader.GetName(1));
+ Assert.Equal("Name", reader.GetName(2));
+ }
+
+ [Fact]
+ public void GetValue_ParsesJulianDateAndTime()
+ {
+ // Julian date 124001 = Jan 1, 2024 (century digit 1 = 2000s, year 24, day 001)
+ // Time 120000 = 12:00:00
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, 124001m, 120000m, "Test" });
+ source.Read().Returns(true);
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+ reader.Read();
+
+ var expectedDate = new DateTime(2024, 1, 1, 12, 0, 0);
+ Assert.Equal(expectedDate, reader.GetValue(1));
+ }
+
+ [Fact]
+ public void GetValue_NullDate_ReturnsDbNull()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, DBNull.Value, DBNull.Value, "Test" });
+ source.Read().Returns(true);
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+ reader.Read();
+
+ Assert.Equal(DBNull.Value, reader.GetValue(1));
+ }
+
+ [Fact]
+ public void GetFieldType_DateColumn_ReturnsDateTime()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, 124001m, 120000m, "Test" });
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(typeof(DateTime), reader.GetFieldType(1));
+ }
+
+ [Fact]
+ public void GetOrdinal_NewDateColumn_ReturnsCorrectOrdinal()
+ {
+ var source = CreateMockReader(
+ new[] { "Id", "UPMJ", "TDAY", "Name" },
+ new object[] { 1, 124001m, 120000m, "Test" });
+
+ var transformer = new JdeDateTransformer("UPMJ", "TDAY", "UpdatedAt");
+ var reader = transformer.Transform(source);
+
+ Assert.Equal(1, reader.GetOrdinal("UpdatedAt"));
+ }
+
+ private static IDataReader CreateMockReader(string[] columns, object[] values)
+ {
+ var reader = Substitute.For();
+ reader.FieldCount.Returns(columns.Length);
+
+ for (int i = 0; i < columns.Length; i++)
+ {
+ var index = i;
+ reader.GetName(index).Returns(columns[index]);
+ reader.GetOrdinal(columns[index]).Returns(index);
+ reader.GetValue(index).Returns(values[index]);
+ reader.IsDBNull(index).Returns(values[index] == DBNull.Value);
+ reader.GetFieldType(index).Returns(values[index]?.GetType() ?? typeof(object));
+ }
+
+ return reader;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~JdeDateTransformerTests" --verbosity normal`
+Expected: FAIL - JdeDateTransformer does not exist
+
+**Step 3: Implement JdeDateTransformer**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs
+using System.Data;
+
+namespace JdeScoping.DataSync.Etl.Transformers;
+
+///
+/// Transforms JDE Julian date (UPMJ) and time (TDAY) columns into a single DateTime column.
+/// JDE Julian date format: CYYDDD where C=century (0=1900s, 1=2000s), YY=year, DDD=day of year.
+/// JDE time format: HHMMSS as decimal.
+///
+public class JdeDateTransformer : DataTransformerBase
+{
+ private readonly string _dateColumn;
+ private readonly string _timeColumn;
+ private readonly string _outputColumn;
+
+ private int _dateOrdinal;
+ private int _timeOrdinal;
+ private int[]? _ordinalMap;
+ private string[]? _outputNames;
+ private Dictionary? _nameToOrdinal;
+
+ public override string TransformerName => $"JdeDate:{_outputColumn}";
+
+ public JdeDateTransformer(string dateColumn, string timeColumn, string outputColumn)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(dateColumn);
+ ArgumentException.ThrowIfNullOrWhiteSpace(timeColumn);
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputColumn);
+
+ _dateColumn = dateColumn;
+ _timeColumn = timeColumn;
+ _outputColumn = outputColumn;
+ }
+
+ protected override void OnInitialize(IDataReader source)
+ {
+ _dateOrdinal = source.GetOrdinal(_dateColumn);
+ _timeOrdinal = source.GetOrdinal(_timeColumn);
+
+ var ordinalList = new List();
+ var nameList = new List();
+ _nameToOrdinal = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ for (int i = 0; i < source.FieldCount; i++)
+ {
+ if (i == _timeOrdinal)
+ continue; // Skip time column
+
+ if (i == _dateOrdinal)
+ {
+ _nameToOrdinal[_outputColumn] = ordinalList.Count;
+ nameList.Add(_outputColumn);
+ }
+ else
+ {
+ var name = source.GetName(i);
+ _nameToOrdinal[name] = ordinalList.Count;
+ nameList.Add(name);
+ }
+ ordinalList.Add(i);
+ }
+
+ _ordinalMap = ordinalList.ToArray();
+ _outputNames = nameList.ToArray();
+ }
+
+ public override int GetFieldCount(IDataReader source) => _ordinalMap!.Length;
+
+ public override string GetName(int ordinal, IDataReader source) => _outputNames![ordinal];
+
+ public override Type GetFieldType(int ordinal, IDataReader source)
+ {
+ var sourceOrdinal = _ordinalMap![ordinal];
+ return sourceOrdinal == _dateOrdinal ? typeof(DateTime) : source.GetFieldType(sourceOrdinal);
+ }
+
+ public override object GetValue(int ordinal, IDataReader source)
+ {
+ var sourceOrdinal = _ordinalMap![ordinal];
+
+ if (sourceOrdinal == _dateOrdinal)
+ return ParseJdeDateTime(source);
+
+ return source.GetValue(sourceOrdinal);
+ }
+
+ public override int GetOrdinal(string name, IDataReader source)
+ {
+ if (_nameToOrdinal!.TryGetValue(name, out var ordinal))
+ return ordinal;
+ throw new IndexOutOfRangeException($"Column '{name}' not found.");
+ }
+
+ public override bool IsDBNull(int ordinal, IDataReader source)
+ {
+ var sourceOrdinal = _ordinalMap![ordinal];
+ if (sourceOrdinal == _dateOrdinal)
+ return source.IsDBNull(_dateOrdinal);
+ return source.IsDBNull(sourceOrdinal);
+ }
+
+ private object ParseJdeDateTime(IDataReader source)
+ {
+ if (source.IsDBNull(_dateOrdinal))
+ return DBNull.Value;
+
+ var julianDate = Convert.ToDecimal(source.GetValue(_dateOrdinal));
+ var timeValue = source.IsDBNull(_timeOrdinal) ? 0m : Convert.ToDecimal(source.GetValue(_timeOrdinal));
+
+ return ParseJdeDateTime(julianDate, timeValue);
+ }
+
+ ///
+ /// Parses JDE Julian date and time into DateTime.
+ ///
+ public static DateTime ParseJdeDateTime(decimal julianDate, decimal time)
+ {
+ // CYYDDD format
+ var dateInt = (int)julianDate;
+ var century = dateInt / 100000;
+ var year = (dateInt / 1000) % 100;
+ var dayOfYear = dateInt % 1000;
+
+ var fullYear = (century == 0 ? 1900 : 2000) + year;
+ var date = new DateTime(fullYear, 1, 1).AddDays(dayOfYear - 1);
+
+ // HHMMSS format
+ var timeInt = (int)time;
+ var hours = timeInt / 10000;
+ var minutes = (timeInt / 100) % 100;
+ var seconds = timeInt % 100;
+
+ return date.AddHours(hours).AddMinutes(minutes).AddSeconds(seconds);
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~JdeDateTransformerTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Transformers/JdeDateTransformer.cs tests/JdeScoping.DataSync.Tests/Etl/Transformers/JdeDateTransformerTests.cs
+git commit -m "feat(etl): implement JdeDateTransformer for Julian date parsing"
+```
+
+---
+
+## Phase 4: Source Implementations
+
+### Task 9: Implement DbQuerySource
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Sources/DbQuerySourceTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Sources/DbQuerySourceTests.cs
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Sources;
+using Microsoft.Data.SqlClient;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Sources;
+
+public class DbQuerySourceTests
+{
+ [Fact]
+ public void Constructor_SetsSourceName()
+ {
+ var factory = Substitute.For();
+
+ var source = new DbQuerySource(factory, "SELECT 1", "TestSource");
+
+ Assert.Equal("DbQuery:TestSource", source.SourceName);
+ }
+
+ [Fact]
+ public void Constructor_NullName_UsesDefault()
+ {
+ var factory = Substitute.For();
+
+ var source = new DbQuerySource(factory, "SELECT 1");
+
+ Assert.Equal("DbQuery:Query", source.SourceName);
+ }
+
+ [Fact]
+ public void Constructor_NullFactory_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() =>
+ new DbQuerySource(null!, "SELECT 1"));
+ }
+
+ [Fact]
+ public void Constructor_NullSql_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new DbQuerySource(factory, null!));
+ }
+
+ [Fact]
+ public void Constructor_EmptySql_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new DbQuerySource(factory, ""));
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbQuerySourceTests" --verbosity normal`
+Expected: FAIL - DbQuerySource does not exist
+
+**Step 3: Implement DbQuerySource**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs
+using System.Data;
+using System.Data.Common;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Contracts;
+using Microsoft.Data.SqlClient;
+
+namespace JdeScoping.DataSync.Etl.Sources;
+
+///
+/// Import source that executes a SQL query and returns results as IDataReader.
+///
+public class DbQuerySource : IImportSource
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly string _sql;
+ private readonly object? _parameters;
+ private readonly int _commandTimeout;
+
+ private SqlConnection? _connection;
+ private SqlCommand? _command;
+
+ public string SourceName { get; }
+
+ public DbQuerySource(
+ IDbConnectionFactory connectionFactory,
+ string sql,
+ string? name = null,
+ object? parameters = null,
+ int commandTimeout = 3600)
+ {
+ ArgumentNullException.ThrowIfNull(connectionFactory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sql);
+
+ _connectionFactory = connectionFactory;
+ _sql = sql;
+ _parameters = parameters;
+ _commandTimeout = commandTimeout;
+ SourceName = $"DbQuery:{name ?? "Query"}";
+ }
+
+ public async Task ReadDataAsync(CancellationToken cancellationToken = default)
+ {
+ _connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken);
+ _command = _connection.CreateCommand();
+ _command.CommandText = _sql;
+ _command.CommandTimeout = _commandTimeout;
+
+ AddParameters(_command, _parameters);
+
+ return await _command.ExecuteReaderAsync(cancellationToken);
+ }
+
+ private static void AddParameters(SqlCommand command, object? parameters)
+ {
+ if (parameters == null)
+ return;
+
+ var properties = parameters.GetType().GetProperties();
+ foreach (var prop in properties)
+ {
+ var value = prop.GetValue(parameters) ?? DBNull.Value;
+ command.Parameters.AddWithValue($"@{prop.Name}", value);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_command != null)
+ {
+ await _command.DisposeAsync();
+ _command = null;
+ }
+ if (_connection != null)
+ {
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbQuerySourceTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Sources/DbQuerySource.cs tests/JdeScoping.DataSync.Tests/Etl/Sources/
+git commit -m "feat(etl): implement DbQuerySource for database queries"
+```
+
+---
+
+## Phase 5: Destination Implementations
+
+### Task 10: Implement DbBulkImportDestination
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkImportDestinationTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkImportDestinationTests.cs
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Destinations;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Destinations;
+
+public class DbBulkImportDestinationTests
+{
+ [Fact]
+ public void Constructor_SetsDestinationName()
+ {
+ var factory = Substitute.For();
+
+ var dest = new DbBulkImportDestination(factory, "WorkOrder");
+
+ Assert.Equal("BulkImport:WorkOrder", dest.DestinationName);
+ }
+
+ [Fact]
+ public void Constructor_NullFactory_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() =>
+ new DbBulkImportDestination(null!, "WorkOrder"));
+ }
+
+ [Fact]
+ public void Constructor_NullTableName_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new DbBulkImportDestination(factory, null!));
+ }
+
+ [Fact]
+ public void Constructor_EmptyTableName_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new DbBulkImportDestination(factory, ""));
+ }
+
+ [Theory]
+ [InlineData(0, 10000)] // 0 means default
+ [InlineData(5000, 5000)]
+ [InlineData(50000, 50000)]
+ public void Constructor_BatchSize_SetsCorrectly(int input, int expected)
+ {
+ var factory = Substitute.For();
+
+ var dest = new DbBulkImportDestination(factory, "WorkOrder", batchSize: input);
+
+ // We can't easily test internal batch size, but construction should succeed
+ Assert.NotNull(dest);
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkImportDestinationTests" --verbosity normal`
+Expected: FAIL - DbBulkImportDestination does not exist
+
+**Step 3: Implement DbBulkImportDestination**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs
+using System.Data;
+using System.Diagnostics;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Contracts;
+using JdeScoping.DataSync.Etl.Results;
+using Microsoft.Data.SqlClient;
+
+namespace JdeScoping.DataSync.Etl.Destinations;
+
+///
+/// Destination that truncates the target table and bulk loads all data.
+///
+public class DbBulkImportDestination : IImportDestination
+{
+ private const int DefaultBatchSize = 10000;
+
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly string _tableName;
+ private readonly int _batchSize;
+
+ public string DestinationName => $"BulkImport:{_tableName}";
+
+ public DbBulkImportDestination(
+ IDbConnectionFactory connectionFactory,
+ string tableName,
+ int batchSize = 0)
+ {
+ ArgumentNullException.ThrowIfNull(connectionFactory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
+
+ _connectionFactory = connectionFactory;
+ _tableName = tableName;
+ _batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
+ }
+
+ public async Task WriteAsync(
+ IDataReader source,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ var stopwatch = Stopwatch.StartNew();
+ long totalRows = 0;
+ int batchCount = 0;
+
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken);
+
+ // Truncate destination table
+ await using (var truncateCmd = connection.CreateCommand())
+ {
+ truncateCmd.CommandText = $"TRUNCATE TABLE [{_tableName}]";
+ await truncateCmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ // Bulk copy data
+ using var bulkCopy = new SqlBulkCopy(connection)
+ {
+ DestinationTableName = $"[{_tableName}]",
+ BatchSize = _batchSize,
+ BulkCopyTimeout = 3600,
+ EnableStreaming = true
+ };
+
+ // Map columns by name
+ for (int i = 0; i < source.FieldCount; i++)
+ {
+ bulkCopy.ColumnMappings.Add(source.GetName(i), source.GetName(i));
+ }
+
+ // Track rows via event
+ bulkCopy.NotifyAfter = _batchSize;
+ bulkCopy.SqlRowsCopied += (_, e) =>
+ {
+ totalRows = e.RowsCopied;
+ batchCount++;
+ };
+
+ await bulkCopy.WriteToServerAsync(source, cancellationToken);
+
+ // Final count (in case NotifyAfter didn't fire for last partial batch)
+ if (bulkCopy.RowsCopied > totalRows)
+ {
+ totalRows = bulkCopy.RowsCopied;
+ batchCount++;
+ }
+
+ stopwatch.Stop();
+ return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed);
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkImportDestinationTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkImportDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/
+git commit -m "feat(etl): implement DbBulkImportDestination for full table refresh"
+```
+
+---
+
+### Task 11: Implement DbBulkMergeDestination
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Destinations;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Destinations;
+
+public class DbBulkMergeDestinationTests
+{
+ [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_ThrowsArgumentException()
+ {
+ var factory = Substitute.For();
+
+ Assert.Throws(() =>
+ new DbBulkMergeDestination(factory, null!, 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!));
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkMergeDestinationTests" --verbosity normal`
+Expected: FAIL - DbBulkMergeDestination does not exist
+
+**Step 3: Implement DbBulkMergeDestination**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs
+using System.Data;
+using System.Diagnostics;
+using System.Text;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Etl.Contracts;
+using JdeScoping.DataSync.Etl.Results;
+using Microsoft.Data.SqlClient;
+
+namespace JdeScoping.DataSync.Etl.Destinations;
+
+///
+/// Destination that uses temp table + MERGE for incremental updates.
+///
+public class DbBulkMergeDestination : IImportDestination
+{
+ private const int DefaultBatchSize = 10000;
+
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly string _tableName;
+ private readonly string[] _matchColumns;
+ private readonly string[]? _updateColumns;
+ private readonly int _batchSize;
+
+ public string DestinationName => $"BulkMerge:{_tableName}";
+
+ public DbBulkMergeDestination(
+ IDbConnectionFactory connectionFactory,
+ string tableName,
+ string[] matchColumns,
+ string[]? updateColumns = null,
+ int batchSize = 0)
+ {
+ ArgumentNullException.ThrowIfNull(connectionFactory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
+ ArgumentNullException.ThrowIfNull(matchColumns);
+ if (matchColumns.Length == 0)
+ throw new ArgumentException("At least one match column is required.", nameof(matchColumns));
+
+ _connectionFactory = connectionFactory;
+ _tableName = tableName;
+ _matchColumns = matchColumns;
+ _updateColumns = updateColumns;
+ _batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
+ }
+
+ public async Task WriteAsync(
+ IDataReader source,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ var stopwatch = Stopwatch.StartNew();
+ long totalRows = 0;
+ int batchCount = 0;
+
+ var tempTableName = $"#ETL_{_tableName.Replace(".", "_").Replace("[", "").Replace("]", "")}";
+
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken);
+
+ try
+ {
+ // Create temp table from destination schema
+ await CreateTempTableAsync(connection, tempTableName, cancellationToken);
+
+ // Get all column names from source
+ var allColumns = new List();
+ for (int i = 0; i < source.FieldCount; i++)
+ allColumns.Add(source.GetName(i));
+
+ // Determine update columns (all non-match columns if not specified)
+ var matchSet = new HashSet(_matchColumns, StringComparer.OrdinalIgnoreCase);
+ var updateCols = _updateColumns ?? allColumns.Where(c => !matchSet.Contains(c)).ToArray();
+
+ // Build MERGE SQL
+ var mergeSql = BuildMergeSql(allColumns, updateCols);
+
+ // Process in batches using DataTable buffer
+ var batch = new DataTable();
+ SetupDataTable(batch, source);
+
+ while (source.Read())
+ {
+ var row = batch.NewRow();
+ for (int i = 0; i < source.FieldCount; i++)
+ row[i] = source.GetValue(i);
+ batch.Rows.Add(row);
+
+ if (batch.Rows.Count >= _batchSize)
+ {
+ batchCount++;
+ await ProcessBatchAsync(connection, batch, tempTableName, mergeSql, cancellationToken);
+ totalRows += batch.Rows.Count;
+ batch.Clear();
+ }
+ }
+
+ // Process remaining rows
+ if (batch.Rows.Count > 0)
+ {
+ batchCount++;
+ await ProcessBatchAsync(connection, batch, tempTableName, mergeSql, cancellationToken);
+ totalRows += batch.Rows.Count;
+ }
+
+ stopwatch.Stop();
+ return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed);
+ }
+ finally
+ {
+ await DropTempTableAsync(connection, tempTableName);
+ }
+ }
+
+ private async Task CreateTempTableAsync(SqlConnection connection, string tempTableName, CancellationToken ct)
+ {
+ var sql = $"SELECT TOP 0 * INTO [{tempTableName}] FROM [{_tableName}]";
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ private async Task DropTempTableAsync(SqlConnection connection, string tempTableName)
+ {
+ try
+ {
+ var sql = $"IF OBJECT_ID('tempdb..{tempTableName}') IS NOT NULL DROP TABLE [{tempTableName}]";
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch { /* Ignore cleanup errors */ }
+ }
+
+ private async Task ProcessBatchAsync(
+ SqlConnection connection,
+ DataTable batch,
+ string tempTableName,
+ string mergeSql,
+ CancellationToken ct)
+ {
+ // Bulk copy to temp table
+ using var bulkCopy = new SqlBulkCopy(connection)
+ {
+ DestinationTableName = tempTableName,
+ BatchSize = batch.Rows.Count
+ };
+ await bulkCopy.WriteToServerAsync(batch, ct);
+
+ // Execute MERGE
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = mergeSql;
+ await cmd.ExecuteNonQueryAsync(ct);
+
+ // Truncate temp table
+ cmd.CommandText = $"TRUNCATE TABLE [{tempTableName}]";
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ private string BuildMergeSql(IReadOnlyList allColumns, IReadOnlyList updateColumns)
+ {
+ var tempTableName = $"#ETL_{_tableName.Replace(".", "_").Replace("[", "").Replace("]", "")}";
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"MERGE INTO [{_tableName}] AS target");
+ sb.AppendLine($"USING [{tempTableName}] AS source");
+ sb.Append("ON ");
+ sb.AppendLine(string.Join(" AND ", _matchColumns.Select(c => $"target.[{c}] = source.[{c}]")));
+
+ if (updateColumns.Count > 0)
+ {
+ sb.AppendLine("WHEN MATCHED THEN UPDATE SET");
+ sb.AppendLine(string.Join(", ", updateColumns.Select(c => $"target.[{c}] = source.[{c}]")));
+ }
+
+ sb.AppendLine("WHEN NOT MATCHED THEN INSERT");
+ sb.AppendLine($"({string.Join(", ", allColumns.Select(c => $"[{c}]"))})");
+ sb.AppendLine($"VALUES ({string.Join(", ", allColumns.Select(c => $"source.[{c}]"))});");
+
+ return sb.ToString();
+ }
+
+ private static void SetupDataTable(DataTable table, IDataReader source)
+ {
+ for (int i = 0; i < source.FieldCount; i++)
+ {
+ var type = source.GetFieldType(i);
+ // Handle nullable types
+ if (type.IsValueType)
+ type = typeof(Nullable<>).MakeGenericType(type);
+ table.Columns.Add(source.GetName(i), Nullable.GetUnderlyingType(type) ?? type);
+ }
+ }
+}
+```
+
+**Step 4: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~DbBulkMergeDestinationTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Destinations/DbBulkMergeDestination.cs tests/JdeScoping.DataSync.Tests/Etl/Destinations/DbBulkMergeDestinationTests.cs
+git commit -m "feat(etl): implement DbBulkMergeDestination for incremental updates"
+```
+
+---
+
+## Phase 6: Pipeline Orchestration
+
+### Task 12: Implement EtlPipeline
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/Pipeline/EtlPipeline.cs`
+- Test: `tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineTests.cs`
+
+**Step 1: Write failing tests**
+
+```csharp
+// tests/JdeScoping.DataSync.Tests/Etl/Pipeline/EtlPipelineTests.cs
+using System.Data;
+using JdeScoping.DataSync.Etl.Contracts;
+using JdeScoping.DataSync.Etl.Pipeline;
+using JdeScoping.DataSync.Etl.Results;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+
+namespace JdeScoping.DataSync.Tests.Etl.Pipeline;
+
+public class EtlPipelineTests
+{
+ [Fact]
+ public async Task ExecuteAsync_SuccessfulPipeline_ReturnsSuccessResult()
+ {
+ var source = CreateMockSource();
+ var destination = CreateMockDestination(100);
+
+ var pipeline = new EtlPipelineBuilder()
+ .WithName("TestPipeline")
+ .WithSource(source)
+ .WithDestination(destination)
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ var result = await pipeline.ExecuteAsync();
+
+ Assert.True(result.Success);
+ Assert.Equal(100, result.TotalRows);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithPreScript_RunsScriptBeforeDestination()
+ {
+ var callOrder = new List();
+
+ var source = CreateMockSource();
+ var destination = CreateMockDestination(100);
+ destination.When(d => d.WriteAsync(Arg.Any(), Arg.Any()))
+ .Do(_ => callOrder.Add("destination"));
+
+ var preScript = Substitute.For();
+ preScript.ScriptName.Returns("PreScript");
+ preScript.When(s => s.ExecuteAsync(Arg.Any()))
+ .Do(_ => callOrder.Add("prescript"));
+
+ var pipeline = new EtlPipelineBuilder()
+ .WithName("TestPipeline")
+ .WithSource(source)
+ .WithDestination(destination)
+ .WithPreScript(preScript)
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ await pipeline.ExecuteAsync();
+
+ Assert.Equal(new[] { "prescript", "destination" }, callOrder);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_DestinationFails_ReturnsFailedResult()
+ {
+ var source = CreateMockSource();
+ var destination = Substitute.For();
+ destination.DestinationName.Returns("FailingDest");
+ destination.WriteAsync(Arg.Any(), Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("Destination failed"));
+
+ var pipeline = new EtlPipelineBuilder()
+ .WithName("TestPipeline")
+ .WithSource(source)
+ .WithDestination(destination)
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ var result = await pipeline.ExecuteAsync();
+
+ Assert.False(result.Success);
+ Assert.NotNull(result.Error);
+ Assert.IsType(result.Error);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_TracksStepResults()
+ {
+ var source = CreateMockSource();
+ var destination = CreateMockDestination(100);
+
+ var pipeline = new EtlPipelineBuilder()
+ .WithName("TestPipeline")
+ .WithSource(source)
+ .WithDestination(destination)
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ var result = await pipeline.ExecuteAsync();
+
+ Assert.Equal(2, result.Steps.Count);
+ Assert.Equal("Source", result.Steps[0].StepType);
+ Assert.Equal("Destination", result.Steps[1].StepType);
+ }
+
+ private static IImportSource CreateMockSource()
+ {
+ var reader = Substitute.For();
+ reader.Read().Returns(false);
+ reader.FieldCount.Returns(0);
+
+ var source = Substitute.For();
+ source.SourceName.Returns("MockSource");
+ source.ReadDataAsync(Arg.Any())
+ .Returns(Task.FromResult(reader));
+ return source;
+ }
+
+ private static IImportDestination CreateMockDestination(long rows)
+ {
+ var destination = Substitute.For();
+ destination.DestinationName.Returns("MockDestination");
+ destination.WriteAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new DestinationResult(rows, 1, TimeSpan.FromSeconds(1))));
+ return destination;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~EtlPipelineTests" --verbosity normal`
+Expected: FAIL - EtlPipeline and EtlPipelineBuilder do not exist
+
+**Step 3: Implement EtlPipeline**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Pipeline/EtlPipeline.cs
+using System.Data;
+using System.Diagnostics;
+using JdeScoping.DataSync.Etl.Contracts;
+using JdeScoping.DataSync.Etl.Results;
+using Microsoft.Extensions.Logging;
+
+namespace JdeScoping.DataSync.Etl.Pipeline;
+
+///
+/// Orchestrates ETL pipeline execution: source → transformers → destination.
+///
+public class EtlPipeline
+{
+ private readonly IImportSource _source;
+ private readonly IReadOnlyList _transformers;
+ private readonly IImportDestination _destination;
+ private readonly IReadOnlyList _preScripts;
+ private readonly IReadOnlyList _postScripts;
+ private readonly ILogger _logger;
+
+ public string PipelineName { get; }
+
+ internal EtlPipeline(
+ string name,
+ IImportSource source,
+ IReadOnlyList transformers,
+ IImportDestination destination,
+ IReadOnlyList preScripts,
+ IReadOnlyList postScripts,
+ ILogger logger)
+ {
+ PipelineName = name;
+ _source = source;
+ _transformers = transformers;
+ _destination = destination;
+ _preScripts = preScripts;
+ _postScripts = postScripts;
+ _logger = logger;
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ var steps = new List();
+ var totalStopwatch = Stopwatch.StartNew();
+
+ _logger.LogInformation("Starting pipeline {PipelineName}", PipelineName);
+
+ try
+ {
+ // 1. Run pre-scripts
+ foreach (var script in _preScripts)
+ {
+ var stepResult = await RunScriptAsync(script, cancellationToken);
+ steps.Add(stepResult);
+ }
+
+ // 2. Open source
+ var sourceStopwatch = Stopwatch.StartNew();
+ await using (_source)
+ {
+ var reader = await _source.ReadDataAsync(cancellationToken);
+ sourceStopwatch.Stop();
+ steps.Add(new StepResult(_source.SourceName, "Source", 0, sourceStopwatch.Elapsed));
+
+ // 3. Apply transformers (chain of decorators)
+ foreach (var transformer in _transformers)
+ {
+ var transformStopwatch = Stopwatch.StartNew();
+ reader = transformer.Transform(reader);
+ transformStopwatch.Stop();
+ steps.Add(new StepResult(transformer.TransformerName, "Transform", 0, transformStopwatch.Elapsed));
+ }
+
+ // 4. Write to destination
+ var destResult = await _destination.WriteAsync(reader, cancellationToken);
+ steps.Add(new StepResult(
+ _destination.DestinationName,
+ "Destination",
+ destResult.RowsProcessed,
+ destResult.Elapsed));
+ }
+
+ // 5. Run post-scripts
+ foreach (var script in _postScripts)
+ {
+ var stepResult = await RunScriptAsync(script, cancellationToken);
+ steps.Add(stepResult);
+ }
+
+ totalStopwatch.Stop();
+ var totalRows = steps.Sum(s => s.RowsAffected);
+
+ _logger.LogInformation(
+ "Pipeline {PipelineName} completed. Rows={Rows}, Elapsed={Elapsed}ms",
+ PipelineName, totalRows, totalStopwatch.ElapsedMilliseconds);
+
+ return PipelineResult.Succeeded(totalRows, totalStopwatch.Elapsed, steps);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ totalStopwatch.Stop();
+ var totalRows = steps.Sum(s => s.RowsAffected);
+
+ _logger.LogError(ex,
+ "Pipeline {PipelineName} failed at step {Step}",
+ PipelineName, steps.LastOrDefault()?.StepName ?? "Unknown");
+
+ return PipelineResult.Failed(totalRows, totalStopwatch.Elapsed, steps, ex);
+ }
+ }
+
+ private async Task RunScriptAsync(IScriptRunner script, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ _logger.LogDebug("Running script {ScriptName}", script.ScriptName);
+ await script.ExecuteAsync(cancellationToken);
+ stopwatch.Stop();
+ return new StepResult(script.ScriptName, "Script", 0, stopwatch.Elapsed);
+ }
+}
+```
+
+**Step 4: Implement EtlPipelineBuilder**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/Pipeline/EtlPipelineBuilder.cs
+using JdeScoping.DataSync.Etl.Contracts;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace JdeScoping.DataSync.Etl.Pipeline;
+
+///
+/// Fluent builder for constructing ETL pipelines.
+///
+public class EtlPipelineBuilder
+{
+ private string _name = "Unnamed";
+ private IImportSource? _source;
+ private readonly List _transformers = new();
+ private IImportDestination? _destination;
+ private readonly List _preScripts = new();
+ private readonly List _postScripts = new();
+ private ILogger? _logger;
+
+ public EtlPipelineBuilder WithName(string name)
+ {
+ _name = name ?? throw new ArgumentNullException(nameof(name));
+ return this;
+ }
+
+ public EtlPipelineBuilder WithSource(IImportSource source)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ return this;
+ }
+
+ public EtlPipelineBuilder WithTransformer(IDataTransformer transformer)
+ {
+ ArgumentNullException.ThrowIfNull(transformer);
+ _transformers.Add(transformer);
+ return this;
+ }
+
+ public EtlPipelineBuilder WithDestination(IImportDestination destination)
+ {
+ _destination = destination ?? throw new ArgumentNullException(nameof(destination));
+ return this;
+ }
+
+ public EtlPipelineBuilder WithPreScript(IScriptRunner script)
+ {
+ ArgumentNullException.ThrowIfNull(script);
+ _preScripts.Add(script);
+ return this;
+ }
+
+ public EtlPipelineBuilder WithPostScript(IScriptRunner script)
+ {
+ ArgumentNullException.ThrowIfNull(script);
+ _postScripts.Add(script);
+ return this;
+ }
+
+ public EtlPipelineBuilder WithLogger(ILogger logger)
+ {
+ _logger = logger;
+ return this;
+ }
+
+ public EtlPipeline Build()
+ {
+ if (_source == null)
+ throw new InvalidOperationException("Source is required. Call WithSource() before Build().");
+ if (_destination == null)
+ throw new InvalidOperationException("Destination is required. Call WithDestination() before Build().");
+
+ return new EtlPipeline(
+ _name,
+ _source,
+ _transformers,
+ _destination,
+ _preScripts,
+ _postScripts,
+ _logger ?? NullLogger.Instance);
+ }
+}
+```
+
+**Step 5: Run tests to verify they pass**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~EtlPipelineTests" --verbosity normal`
+Expected: All tests pass
+
+**Step 6: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/Pipeline/ tests/JdeScoping.DataSync.Tests/Etl/Pipeline/
+git commit -m "feat(etl): implement EtlPipeline and EtlPipelineBuilder"
+```
+
+---
+
+## Phase 7: Configuration and DI
+
+### Task 13: Add DI Registration
+
+**Files:**
+- Create: `src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs`
+- Modify: `src/JdeScoping.DataSync/DependencyInjection.cs`
+
+**Step 1: Create ETL DI extensions**
+
+```csharp
+// src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs
+using JdeScoping.DataSync.Etl.Pipeline;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace JdeScoping.DataSync.Etl;
+
+///
+/// Extension methods for registering ETL pipeline services.
+///
+public static class EtlServiceCollectionExtensions
+{
+ ///
+ /// Adds ETL pipeline services to the service collection.
+ ///
+ public static IServiceCollection AddEtlPipeline(this IServiceCollection services)
+ {
+ // Register factory for creating pipelines
+ services.AddTransient();
+
+ return services;
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/JdeScoping.DataSync/Etl/EtlServiceCollectionExtensions.cs
+git commit -m "feat(etl): add DI registration for ETL pipeline"
+```
+
+---
+
+### Task 14: Run Full Test Suite
+
+**Step 1: Build the solution**
+
+Run: `dotnet build NEW/`
+Expected: Build succeeds
+
+**Step 2: Run all DataSync tests**
+
+Run: `dotnet test tests/JdeScoping.DataSync.Tests --verbosity normal`
+Expected: All tests pass
+
+**Step 3: Commit any fixes if needed**
+
+---
+
+## Summary
+
+This implementation plan creates a complete ETL pipeline system with:
+
+1. **Core Interfaces**: `IImportSource`, `IDataTransformer`, `IImportDestination`, `IScriptRunner`
+2. **Result Models**: `PipelineResult`, `StepResult`, `DestinationResult`
+3. **Transformers**: `TransformingDataReader`, `DataTransformerBase`, `ColumnDropTransformer`, `ColumnRenameTransformer`, `JdeDateTransformer`
+4. **Sources**: `DbQuerySource`
+5. **Destinations**: `DbBulkImportDestination`, `DbBulkMergeDestination`
+6. **Scripts**: `SqlScriptRunner`, `CommonScripts`
+7. **Pipeline**: `EtlPipeline`, `EtlPipelineBuilder`
+
+Each task follows TDD with failing tests first, then implementation, then verification.
diff --git a/PLANS/2026-01-03-options-relocation-design.md b/PLANS/2026-01-03-options-relocation-design.md
new file mode 100644
index 0000000..cb6ab84
--- /dev/null
+++ b/PLANS/2026-01-03-options-relocation-design.md
@@ -0,0 +1,116 @@
+# Options Classes Relocation Design
+
+## Summary
+
+Move options classes from Core project to the projects that use them, establishing an `Options/` folder convention across all projects.
+
+## Current State
+
+All 7 options classes live in `JdeScoping.Core/Options/`:
+- `AuthOptions` - used by Api + Infrastructure
+- `DataSourceOptions` - used by Infrastructure
+- `DataSyncOptions` - duplicate (real one in DataSync)
+- `ExcelExportOptions` - duplicate (real one in ExcelIO)
+- `LdapOptions` - used by Infrastructure
+- `SearchOptions` - unused (future use in DataAccess)
+- `SearchProcessingOptions` - used by DataAccess
+
+## Target State
+
+### Folder Structure
+
+```
+src/
+├── JdeScoping.Api/
+│ └── Options/
+│ └── AuthOptions.cs
+├── JdeScoping.DataAccess/
+│ └── Options/
+│ ├── SearchOptions.cs
+│ └── SearchProcessingOptions.cs
+├── JdeScoping.Infrastructure/
+│ └── Options/
+│ ├── DataSourceOptions.cs
+│ └── LdapOptions.cs
+├── JdeScoping.DataSync/
+│ └── Options/ # Renamed from Configuration/
+│ └── DataSyncOptions.cs
+├── JdeScoping.ExcelIO/
+│ └── Options/ # Renamed from Configuration/
+│ └── ExcelExportOptions.cs
+└── JdeScoping.Core/
+ └── (Options folder deleted)
+```
+
+### Property Moves
+
+**LdapOptions** gains properties from AuthOptions:
+- `UseFakeAuth` (bool, default: false)
+- `AdminBypassUsers` (string[], default: [])
+
+**AuthOptions** loses those properties, keeping only:
+- `CookieName` (string, default: "ScopingTool.Auth")
+- `CookieExpirationMinutes` (int, default: 480)
+
+### Namespace Changes
+
+| Old Namespace | New Namespace |
+|---------------|---------------|
+| `JdeScoping.Core.Options.AuthOptions` | `JdeScoping.Api.Options.AuthOptions` |
+| `JdeScoping.Core.Options.SearchOptions` | `JdeScoping.DataAccess.Options.SearchOptions` |
+| `JdeScoping.Core.Options.SearchProcessingOptions` | `JdeScoping.DataAccess.Options.SearchProcessingOptions` |
+| `JdeScoping.Core.Options.DataSourceOptions` | `JdeScoping.Infrastructure.Options.DataSourceOptions` |
+| `JdeScoping.Core.Options.LdapOptions` | `JdeScoping.Infrastructure.Options.LdapOptions` |
+| `JdeScoping.DataSync.Configuration.*` | `JdeScoping.DataSync.Options.*` |
+| `JdeScoping.ExcelIO.Configuration.*` | `JdeScoping.ExcelIO.Options.*` |
+
+### DI Registration Changes
+
+**Infrastructure/DependencyInjection.cs:**
+- Remove `AuthOptions` registration
+- Change `authOptions?.UseFakeAuth` check to use `ldapOptions?.UseFakeAuth`
+
+**Api/DependencyInjection.cs:**
+- Add `AuthOptions` registration
+
+### Configuration (appsettings.json)
+
+Move settings from `"Auth"` to `"Ldap"` section:
+```json
+{
+ "Ldap": {
+ "ServerUrls": ["ldap.corp.example.com"],
+ "GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com",
+ "SearchBase": "DC=corp,DC=example,DC=com",
+ "ConnectionTimeoutSeconds": 30,
+ "UseFakeAuth": false,
+ "AdminBypassUsers": []
+ },
+ "Auth": {
+ "CookieName": "ScopingTool.Auth",
+ "CookieExpirationMinutes": 480
+ }
+}
+```
+
+## Files to Delete
+
+- `src/JdeScoping.Core/Options/AuthOptions.cs`
+- `src/JdeScoping.Core/Options/DataSourceOptions.cs`
+- `src/JdeScoping.Core/Options/DataSyncOptions.cs`
+- `src/JdeScoping.Core/Options/ExcelExportOptions.cs`
+- `src/JdeScoping.Core/Options/LdapOptions.cs`
+- `src/JdeScoping.Core/Options/SearchOptions.cs`
+- `src/JdeScoping.Core/Options/SearchProcessingOptions.cs`
+- `src/JdeScoping.Core/Options/` (folder)
+
+## Files to Update
+
+- `Host/Program.cs` - update using statements
+- `Infrastructure/DependencyInjection.cs` - remove AuthOptions, use LdapOptions for UseFakeAuth
+- `Infrastructure/Auth/LdapAuthService.cs` - update using, use LdapOptions for AdminBypassUsers
+- `Api/DependencyInjection.cs` - add AuthOptions registration
+- `DataAccess/DependencyInjection.cs` - update using statements
+- `DataSync/**` - rename Configuration namespace to Options
+- `ExcelIO/**` - rename Configuration namespace to Options
+- `appsettings.json` - move UseFakeAuth/AdminBypassUsers to Ldap section