Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,12 @@
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// Test entity for BulkMergeHelper integration tests.
/// </summary>
public class BulkMergeTestEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal? Amount { get; set; }
public DateTime LastUpdateDt { get; set; }
}
@@ -0,0 +1,34 @@
using System.Data;
using JdeScoping.DataSync.Generated;
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// IDataReader implementation for BulkMergeTestEntity.
/// </summary>
public sealed class BulkMergeTestEntityDataReader : AsyncEnumerableDataReader<BulkMergeTestEntity>
{
private static readonly string[] _columnNames = ["Id", "Name", "Amount", "LastUpdateDt"];
private static readonly Type[] _columnTypes = [typeof(int), typeof(string), typeof(decimal), typeof(DateTime)];
public BulkMergeTestEntityDataReader(IAsyncEnumerable<BulkMergeTestEntity> source) : base(source) { }
protected override string[] ColumnNames => _columnNames;
public static IReadOnlyList<string> GetColumnNames() => _columnNames;
protected override object GetColumnValue(int ordinal)
{
var entity = Current!;
return ordinal switch
{
0 => entity.Id,
1 => entity.Name,
2 => entity.Amount ?? (object)DBNull.Value,
3 => entity.LastUpdateDt,
_ => throw new IndexOutOfRangeException()
};
}
protected override Type GetColumnType(int ordinal) => _columnTypes[ordinal];
}
@@ -0,0 +1,88 @@
using Testcontainers.MsSql;
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// Shared fixture that manages the SQL Server Testcontainer lifecycle.
/// Container is started once per test collection and shared across all tests.
/// </summary>
public class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container;
/// <summary>
/// Gets the connection string to the test SQL Server instance.
/// </summary>
public string ConnectionString => _container.GetConnectionString();
public SqlServerFixture()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Test@Password123!")
.Build();
}
/// <summary>
/// Starts the container and initializes the test database schema.
/// </summary>
public async Task InitializeAsync()
{
await _container.StartAsync();
await TestDatabaseInitializer.InitializeAsync(ConnectionString);
}
/// <summary>
/// Stops and disposes the container.
/// </summary>
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
/// <summary>
/// Creates a new open connection to the test database.
/// Caller is responsible for disposing the connection.
/// </summary>
public async Task<SqlConnection> CreateConnectionAsync()
{
var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
return connection;
}
/// <summary>
/// Truncates all test tables to ensure clean state between tests.
/// </summary>
public async Task CleanupTablesAsync()
{
await using var connection = await CreateConnectionAsync();
await connection.ExecuteAsync(@"
TRUNCATE TABLE WorkOrder_Test;
TRUNCATE TABLE Item_Test;
TRUNCATE TABLE LotUsage_Test;
TRUNCATE TABLE DataUpdate_Test;
TRUNCATE TABLE BulkMergeTest;
");
}
/// <summary>
/// Cleans up just the BulkMergeTest table.
/// </summary>
public async Task CleanupBulkMergeTestTableAsync()
{
await using var connection = await CreateConnectionAsync();
await connection.ExecuteAsync("TRUNCATE TABLE BulkMergeTest;");
}
}
/// <summary>
/// Collection definition for sharing the SQL Server fixture across test classes.
/// </summary>
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<SqlServerFixture>
{
// This class has no code, and is never created.
// Its purpose is to be the place to apply [CollectionDefinition]
// and all the ICollectionFixture<> interfaces.
}
@@ -0,0 +1,142 @@
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// Generates test data for integration tests.
/// </summary>
public static class TestDataGenerator
{
/// <summary>
/// Generates a list of WorkOrder entities with sequential IDs.
/// </summary>
public static List<WorkOrderTestEntity> GenerateWorkOrders(int count, DateTime? baseTime = null)
{
var time = baseTime ?? DateTime.UtcNow;
return Enumerable.Range(1, count)
.Select(i => new WorkOrderTestEntity
{
OrderNumber = i,
Status = i % 2 == 0 ? "Active" : "Closed",
Description = $"Work Order {i}",
Quantity = i * 10.5m,
LastUpdateDT = time.AddMinutes(-i)
})
.ToList();
}
/// <summary>
/// Generates WorkOrders with duplicate primary keys (for deduplication testing).
/// Each OrderNumber appears twice with different timestamps.
/// </summary>
public static List<WorkOrderTestEntity> GenerateWorkOrdersWithDuplicates(int uniqueCount, DateTime baseTime)
{
var orders = new List<WorkOrderTestEntity>();
for (var i = 1; i <= uniqueCount; i++)
{
// Older version
orders.Add(new WorkOrderTestEntity
{
OrderNumber = i,
Status = "Old",
Description = $"Work Order {i} - Old",
Quantity = i * 10m,
LastUpdateDT = baseTime.AddHours(-2)
});
// Newer version (should be kept after deduplication)
orders.Add(new WorkOrderTestEntity
{
OrderNumber = i,
Status = "New",
Description = $"Work Order {i} - New",
Quantity = i * 20m,
LastUpdateDT = baseTime
});
}
return orders;
}
/// <summary>
/// Generates Item entities (no LastUpdateDT column).
/// </summary>
public static List<ItemTestEntity> GenerateItems(int count)
{
return Enumerable.Range(1, count)
.Select(i => new ItemTestEntity
{
ItemNumber = $"ITEM{i:D6}",
Description = $"Item {i}",
UnitOfMeasure = i % 3 == 0 ? "EA" : (i % 3 == 1 ? "KG" : "LB")
})
.ToList();
}
/// <summary>
/// Generates LotUsage entities with composite primary key.
/// </summary>
public static List<LotUsageTestEntity> GenerateLotUsages(int count, DateTime? baseTime = null)
{
var time = baseTime ?? DateTime.UtcNow;
return Enumerable.Range(1, count)
.Select(i => new LotUsageTestEntity
{
LotNumber = $"LOT{i:D6}",
OrderNumber = (i % 10) + 1, // Reuse order numbers
Quantity = i * 5.25m,
LastUpdateDT = time.AddMinutes(-i)
})
.ToList();
}
/// <summary>
/// Generates a large dataset for batching tests.
/// </summary>
public static List<WorkOrderTestEntity> GenerateLargeDataset(int count)
{
var time = DateTime.UtcNow;
return Enumerable.Range(1, count)
.Select(i => new WorkOrderTestEntity
{
OrderNumber = i,
Status = "Active",
Description = $"WO-{i}",
Quantity = i,
LastUpdateDT = time
})
.ToList();
}
}
/// <summary>
/// Test entity matching WorkOrder_Test table schema.
/// </summary>
public class WorkOrderTestEntity
{
public int OrderNumber { get; set; }
public string? Status { get; set; }
public string? Description { get; set; }
public decimal? Quantity { get; set; }
public DateTime LastUpdateDT { get; set; }
}
/// <summary>
/// Test entity matching Item_Test table schema (no LastUpdateDT).
/// </summary>
public class ItemTestEntity
{
public string ItemNumber { get; set; } = string.Empty;
public string? Description { get; set; }
public string? UnitOfMeasure { get; set; }
}
/// <summary>
/// Test entity matching LotUsage_Test table schema (composite PK).
/// </summary>
public class LotUsageTestEntity
{
public string LotNumber { get; set; } = string.Empty;
public int OrderNumber { get; set; }
public decimal? Quantity { get; set; }
public DateTime LastUpdateDT { get; set; }
}
@@ -0,0 +1,37 @@
using System.Data;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Generated;
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// DataReaderFactory for integration tests that supports both test entities and production entities.
/// </summary>
public class TestDataReaderFactory : IDataReaderFactory
{
private readonly DataReaderFactory _innerFactory = new();
public IDataReader CreateReader<T>(IAsyncEnumerable<T> source) where T : class
{
// Handle test entity
if (typeof(T) == typeof(BulkMergeTestEntity))
{
return new BulkMergeTestEntityDataReader((IAsyncEnumerable<BulkMergeTestEntity>)(object)source);
}
// Delegate to production factory for other types
return _innerFactory.CreateReader(source);
}
public IReadOnlyList<string> GetColumnNames<T>() where T : class
{
// Handle test entity
if (typeof(T) == typeof(BulkMergeTestEntity))
{
return BulkMergeTestEntityDataReader.GetColumnNames();
}
// Delegate to production factory for other types
return _innerFactory.GetColumnNames<T>();
}
}
@@ -0,0 +1,91 @@
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// Initializes the test database schema.
/// Creates test tables that mirror production schemas.
/// </summary>
public static class TestDatabaseInitializer
{
/// <summary>
/// Creates all test tables in the database.
/// </summary>
public static async Task InitializeAsync(string connectionString)
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
// WorkOrder_Test: For MERGE and bulk copy tests (has LastUpdateDT)
await connection.ExecuteAsync(@"
IF OBJECT_ID('WorkOrder_Test', 'U') IS NOT NULL
DROP TABLE WorkOrder_Test;
CREATE TABLE WorkOrder_Test (
OrderNumber INT NOT NULL PRIMARY KEY,
Status VARCHAR(10) NULL,
Description VARCHAR(100) NULL,
Quantity DECIMAL(18,4) NULL,
LastUpdateDT DATETIME2 NOT NULL
);
CREATE NONCLUSTERED INDEX IX_WorkOrder_Test_Status
ON WorkOrder_Test(Status);
");
// Item_Test: For tables WITHOUT LastUpdateDT (unconditional update)
await connection.ExecuteAsync(@"
IF OBJECT_ID('Item_Test', 'U') IS NOT NULL
DROP TABLE Item_Test;
CREATE TABLE Item_Test (
ItemNumber VARCHAR(25) NOT NULL PRIMARY KEY,
Description VARCHAR(100) NULL,
UnitOfMeasure VARCHAR(10) NULL
);
");
// LotUsage_Test: For composite primary key tests
await connection.ExecuteAsync(@"
IF OBJECT_ID('LotUsage_Test', 'U') IS NOT NULL
DROP TABLE LotUsage_Test;
CREATE TABLE LotUsage_Test (
LotNumber VARCHAR(30) NOT NULL,
OrderNumber INT NOT NULL,
Quantity DECIMAL(18,4) NULL,
LastUpdateDT DATETIME2 NOT NULL,
CONSTRAINT PK_LotUsage_Test PRIMARY KEY (LotNumber, OrderNumber)
);
");
// DataUpdate_Test: For update logging tests
await connection.ExecuteAsync(@"
IF OBJECT_ID('DataUpdate_Test', 'U') IS NOT NULL
DROP TABLE DataUpdate_Test;
CREATE TABLE DataUpdate_Test (
Id INT IDENTITY(1,1) PRIMARY KEY,
TableName VARCHAR(50) NOT NULL,
SourceSystem VARCHAR(10) NOT NULL,
SourceData VARCHAR(50) NOT NULL,
UpdateType INT NOT NULL,
StartDT DATETIME2 NOT NULL,
EndDT DATETIME2 NULL,
NumberRecords INT NOT NULL,
WasSuccessful BIT NULL
);
");
// BulkMergeTest: For BulkMergeHelper integration tests
await connection.ExecuteAsync(@"
IF OBJECT_ID('BulkMergeTest', 'U') IS NOT NULL
DROP TABLE BulkMergeTest;
CREATE TABLE BulkMergeTest (
Id INT NOT NULL PRIMARY KEY,
Name NVARCHAR(100) NOT NULL,
Amount DECIMAL(18,2) NULL,
LastUpdateDt DATETIME2 NOT NULL
);
");
}
}
@@ -0,0 +1,39 @@
using JdeScoping.DataAccess.Interfaces;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DataSync.IntegrationTests.Infrastructure;
/// <summary>
/// Connection factory for integration tests that uses the test container connection string.
/// </summary>
public class TestDbConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public TestDbConnectionFactory(string connectionString)
{
_connectionString = connectionString;
}
public async Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default)
{
var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
return connection;
}
public Task<OracleConnection> CreateJdeConnectionAsync(CancellationToken ct = default)
{
throw new NotImplementedException("JDE connection not supported in integration tests");
}
public Task<OracleConnection> CreateJdeStageConnectionAsync(CancellationToken ct = default)
{
throw new NotImplementedException("JDE Stage connection not supported in integration tests");
}
public Task<OracleConnection> CreateCmsConnectionAsync(CancellationToken ct = default)
{
throw new NotImplementedException("CMS connection not supported in integration tests");
}
}