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,228 @@
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests;
/// <summary>
/// Unit tests for CmsRepository.
/// </summary>
public class CmsRepositoryTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<CmsRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
public CmsRepositoryTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_logger = Substitute.For<ILogger<CmsRepository>>();
_options = Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
MisDataTimeoutSeconds = 60000
});
}
#region Constructor Tests
[Fact]
public void Constructor_NullConnectionFactory_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new CmsRepository(null!, _logger, _options))
.ParamName.ShouldBe("connectionFactory");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new CmsRepository(_connectionFactory, null!, _options))
.ParamName.ShouldBe("logger");
}
[Fact]
public void Constructor_NullOptions_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new CmsRepository(_connectionFactory, _logger, null!))
.ParamName.ShouldBe("options");
}
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Act
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Assert
repository.ShouldNotBeNull();
}
#endregion
#region GetMisDataAsync Tests
[Fact]
public async Task GetMisDataAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateCmsConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "CMS"));
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetMisDataAsync())
{
}
});
ex.DataSource.ShouldBe("CMS");
}
[Fact]
public async Task GetMisDataAsync_UsesCmsConnection()
{
// Arrange
_connectionFactory.CreateCmsConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "CMS"));
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Act
try
{
await foreach (var _ in repository.GetMisDataAsync())
{
}
}
catch (ConnectionException)
{
// Expected
}
// Assert - verify correct connection factory method was called
await _connectionFactory.Received(1).CreateCmsConnectionAsync(Arg.Any<CancellationToken>());
}
#endregion
#region Cancellation Tests
[Fact]
public async Task GetMisDataAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
_connectionFactory.CreateCmsConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () =>
{
await foreach (var _ in repository.GetMisDataAsync(ct: cts.Token))
{
}
});
}
#endregion
#region Incremental Sync Tests
[Fact]
public async Task GetMisDataAsync_WithLastUpdateDT_UsesFilteredQuery()
{
// Arrange
var lastUpdate = new DateTime(2024, 1, 15, 10, 30, 0);
_connectionFactory.CreateCmsConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "CMS"));
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Act & Assert - this just verifies the method accepts the parameter
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetMisDataAsync(lastUpdate))
{
}
});
}
[Fact]
public async Task GetMisDataAsync_WithoutLastUpdateDT_UsesFullQuery()
{
// Arrange
_connectionFactory.CreateCmsConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "CMS"));
var repository = new CmsRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetMisDataAsync())
{
}
});
}
#endregion
#region Timeout Configuration Tests
[Fact]
public void Constructor_UsesMisDataTimeout()
{
// Arrange
var customOptions = Options.Create(new DataAccessOptions
{
MisDataTimeoutSeconds = 999999
});
// Act
var repository = new CmsRepository(_connectionFactory, _logger, customOptions);
// Assert
repository.ShouldNotBeNull();
// The timeout value is internal, verified through behavior
}
[Fact]
public void Constructor_DefaultMisDataTimeout_Is60000Seconds()
{
// Arrange
var defaultOptions = Options.Create(new DataAccessOptions());
// Act
var repository = new CmsRepository(_connectionFactory, _logger, defaultOptions);
// Assert
repository.ShouldNotBeNull();
// Default timeout of 60000 seconds is verified implicitly
}
#endregion
}
@@ -0,0 +1,234 @@
using JdeScoping.DataAccess.Exceptions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests;
/// <summary>
/// Unit tests for DbConnectionFactory.
/// </summary>
public class DbConnectionFactoryTests
{
private readonly IConfiguration _configuration;
private readonly ILogger<DbConnectionFactory> _logger;
public DbConnectionFactoryTests()
{
_configuration = Substitute.For<IConfiguration>();
_logger = Substitute.For<ILogger<DbConnectionFactory>>();
}
#region Constructor Tests
[Fact]
public void Constructor_NullConfiguration_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DbConnectionFactory(null!, _logger))
.ParamName.ShouldBe("configuration");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DbConnectionFactory(_configuration, null!))
.ParamName.ShouldBe("logger");
}
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Act
var factory = new DbConnectionFactory(_configuration, _logger);
// Assert
factory.ShouldNotBeNull();
}
#endregion
#region CreateLotFinderConnectionAsync Tests
[Fact]
public async Task CreateLotFinderConnectionAsync_MissingConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("LotFinderDB").Returns((string?)null);
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateLotFinderConnectionAsync());
ex.DataSource.ShouldBe("LotFinderDB");
ex.Message.ShouldContain("Connection string not found");
}
[Fact]
public async Task CreateLotFinderConnectionAsync_EmptyConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("LotFinderDB").Returns(string.Empty);
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateLotFinderConnectionAsync());
ex.DataSource.ShouldBe("LotFinderDB");
ex.Message.ShouldContain("Connection string not found");
}
[Fact]
public async Task CreateLotFinderConnectionAsync_InvalidConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("LotFinderDB").Returns("Invalid connection string");
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateLotFinderConnectionAsync());
ex.DataSource.ShouldBe("LotFinderDB");
ex.Message.ShouldContain("Failed to open connection");
ex.InnerException.ShouldNotBeNull();
}
[Fact]
public async Task CreateLotFinderConnectionAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
_configuration.GetConnectionString("LotFinderDB").Returns("Server=test;Database=test;");
var factory = new DbConnectionFactory(_configuration, _logger);
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () => await factory.CreateLotFinderConnectionAsync(cts.Token));
}
#endregion
#region CreateJdeConnectionAsync Tests
[Fact]
public async Task CreateJdeConnectionAsync_MissingConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("JDE").Returns((string?)null);
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateJdeConnectionAsync());
ex.DataSource.ShouldBe("JDE");
ex.Message.ShouldContain("Connection string not found");
}
[Fact]
public async Task CreateJdeConnectionAsync_InvalidConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("JDE").Returns("Invalid oracle connection");
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateJdeConnectionAsync());
ex.DataSource.ShouldBe("JDE");
ex.Message.ShouldContain("Failed to open connection");
}
#endregion
#region CreateJdeStageConnectionAsync Tests
[Fact]
public async Task CreateJdeStageConnectionAsync_MissingConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("JDEStage").Returns((string?)null);
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateJdeStageConnectionAsync());
ex.DataSource.ShouldBe("JDEStage");
ex.Message.ShouldContain("Connection string not found");
}
#endregion
#region CreateCmsConnectionAsync Tests
[Fact]
public async Task CreateCmsConnectionAsync_MissingConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("CMS").Returns((string?)null);
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateCmsConnectionAsync());
ex.DataSource.ShouldBe("CMS");
ex.Message.ShouldContain("Connection string not found");
}
[Fact]
public async Task CreateCmsConnectionAsync_InvalidConnectionString_ThrowsConnectionException()
{
// Arrange
_configuration.GetConnectionString("CMS").Returns("Invalid oracle connection");
var factory = new DbConnectionFactory(_configuration, _logger);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () => await factory.CreateCmsConnectionAsync());
ex.DataSource.ShouldBe("CMS");
ex.Message.ShouldContain("Failed to open connection");
}
#endregion
#region Logging Tests
[Fact]
public async Task CreateLotFinderConnectionAsync_InvalidConnection_LogsError()
{
// Arrange
_configuration.GetConnectionString("LotFinderDB").Returns("Invalid connection string");
var factory = new DbConnectionFactory(_configuration, _logger);
// Act
try
{
await factory.CreateLotFinderConnectionAsync();
}
catch (ConnectionException)
{
// Expected
}
// Assert - verify error logging was called (at least once - there may also be debug logs)
_logger.Received().Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
#endregion
}
@@ -0,0 +1,480 @@
using System.Data;
using System.Reflection;
using Dapper;
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests.Extensions;
/// <summary>
/// Unit tests for TableValuedParameterExtensions.
/// </summary>
public sealed class TableValuedParameterExtensionsTests
{
#region CreateWorkOrderFilterParameter Tests
[Fact]
public void CreateWorkOrderFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC" }
]
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("WorkOrderNumber").ShouldBeTrue();
dataTable.Columns["WorkOrderNumber"]!.DataType.ShouldBe(typeof(long));
}
[Fact]
public void CreateWorkOrderFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter = []
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateWorkOrderFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 },
new WorkOrderFilterEntry { WorkOrderNumber = 67890 }
]
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(2);
dataTable.Rows[0]["WorkOrderNumber"].ShouldBe(12345L);
dataTable.Rows[1]["WorkOrderNumber"].ShouldBe(67890L);
}
#endregion
#region CreateItemNumberFilterParameter Tests
[Fact]
public void CreateItemNumberFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ItemNumberFilter =
[
new ItemNumberFilterEntry { ItemNumber = "ABC123" }
]
};
// Act
var param = model.CreateItemNumberFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateItemNumberFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ItemNumberFilter = []
};
// Act
var param = model.CreateItemNumberFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateProfitCenterFilterParameter Tests
[Fact]
public void CreateProfitCenterFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ProfitCenterFilter =
[
new ProfitCenterFilterEntry { Code = "PC001" }
]
};
// Act
var param = model.CreateProfitCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("Code").ShouldBeTrue();
dataTable.Columns["Code"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateProfitCenterFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ProfitCenterFilter = []
};
// Act
var param = model.CreateProfitCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateWorkCenterFilterParameter Tests
[Fact]
public void CreateWorkCenterFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
WorkCenterFilter =
[
new WorkCenterFilterEntry { Code = "WC001" }
]
};
// Act
var param = model.CreateWorkCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("Code").ShouldBeTrue();
dataTable.Columns["Code"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateWorkCenterFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
WorkCenterFilter = []
};
// Act
var param = model.CreateWorkCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateComponentLotFilterParameter Tests
[Fact]
public void CreateComponentLotFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(2);
dataTable.Columns.Contains("ComponentLotNumber").ShouldBeTrue();
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns["ComponentLotNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateComponentLotFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter = []
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateComponentLotFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" },
new ComponentLotFilterEntry { LotNumber = "LOT002", ItemNumber = "ITEM002" }
]
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(2);
dataTable.Rows[0]["ComponentLotNumber"].ShouldBe("LOT001");
dataTable.Rows[0]["ItemNumber"].ShouldBe("ITEM001");
}
#endregion
#region CreateOperatorFilterParameter Tests
[Fact]
public void CreateOperatorFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
OperatorFilter =
[
new OperatorFilterEntry { UserId = "USER01", AddressNumber = 123 }
]
};
// Act
var param = model.CreateOperatorFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("UserName").ShouldBeTrue();
dataTable.Columns["UserName"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateOperatorFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
OperatorFilter = []
};
// Act
var param = model.CreateOperatorFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateItemOperationMisFilterParameter Tests
[Fact]
public void CreateItemOperationMisFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter =
[
new ItemOperationMisFilterEntry
{
ItemNumber = "ITEM001",
OperationNumber = "010",
MisNumber = "MIS001",
MisRevision = "A"
}
]
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(4);
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns.Contains("OperationNumber").ShouldBeTrue();
dataTable.Columns.Contains("MisNumber").ShouldBeTrue();
dataTable.Columns.Contains("MisRevision").ShouldBeTrue();
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["OperationNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["MisNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["MisRevision"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateItemOperationMisFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter = []
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateItemOperationMisFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter =
[
new ItemOperationMisFilterEntry
{
ItemNumber = "ITEM001",
OperationNumber = "010",
MisNumber = "MIS001",
MisRevision = "A"
}
]
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(1);
dataTable.Rows[0]["ItemNumber"].ShouldBe("ITEM001");
dataTable.Rows[0]["OperationNumber"].ShouldBe("010");
dataTable.Rows[0]["MisNumber"].ShouldBe("MIS001");
dataTable.Rows[0]["MisRevision"].ShouldBe("A");
}
#endregion
#region Helper Methods
/// <summary>
/// Extracts the underlying DataTable from a Dapper table-valued parameter.
/// Uses reflection to access internal fields across different Dapper versions.
/// </summary>
private static DataTable ExtractDataTable(SqlMapper.ICustomQueryParameter param)
{
// The TableValuedParameter wraps a DataTable - try multiple field/property names
// across different Dapper versions
var type = param.GetType();
var bindingFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
// Try field names used in different Dapper versions
var fieldNames = new[] { "_table", "table", "Table", "_dataTable", "dataTable" };
foreach (var fieldName in fieldNames)
{
var field = type.GetField(fieldName, bindingFlags);
if (field != null && field.FieldType == typeof(DataTable))
{
var value = field.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Try property names
var propertyNames = new[] { "Table", "DataTable", "table", "_table" };
foreach (var propName in propertyNames)
{
var prop = type.GetProperty(propName, bindingFlags);
if (prop != null && prop.PropertyType == typeof(DataTable))
{
var value = prop.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Last resort: scan all fields
foreach (var field in type.GetFields(bindingFlags))
{
if (field.FieldType == typeof(DataTable))
{
var value = field.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Scan all properties
foreach (var prop in type.GetProperties(bindingFlags))
{
if (prop.PropertyType == typeof(DataTable))
{
var value = prop.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
throw new InvalidOperationException(
$"Could not extract DataTable from {type.FullName}. " +
$"Fields: {string.Join(", ", type.GetFields(bindingFlags).Select(f => f.Name))}. " +
$"Properties: {string.Join(", ", type.GetProperties(bindingFlags).Select(p => p.Name))}");
}
#endregion
}
@@ -0,0 +1,196 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using SqlKata.Compilers;
using Xunit;
namespace JdeScoping.DataAccess.Tests.FilterHandlers;
/// <summary>
/// Unit tests for ComponentLotFilterHandler.
/// </summary>
public sealed class ComponentLotFilterHandlerTests
{
private readonly SqlServerCompiler _compiler = new();
private readonly ComponentLotFilterHandler _handler = new();
[Fact]
public void IsEnabled_WithComponentLotFilters_ReturnsTrue()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsEnabled_WithEmptyComponentLotFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter = []
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsEnabled_WithNullComponentLotFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel();
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void Apply_GeneratedSql_ContainsWorkOrderComponentJoin()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.ShouldNotBeNull();
result.SetupSql.ShouldNotBeEmpty();
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("dbo.WorkOrderComponent AS woc");
}
[Fact]
public void Apply_GeneratedSql_ContainsLotUsageJoin()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("dbo.LotUsage AS lu");
}
[Fact]
public void Apply_GeneratedSql_SetsCARDEXFlag()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
// CARDEX flag is set (not PartsList) per the ComponentLotFilterHandler implementation
allSql.ShouldContain("TARGET.CARDEX = 1");
}
[Fact]
public void Apply_GeneratedSql_DoesNotSetPartsListFlag()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
// ComponentLotFilterHandler sets CARDEX, not PartsList
allSql.ShouldNotContain("PartsList = 1");
}
[Fact]
public void Apply_GeneratedSql_ContainsSplitOrderLogic()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("SplitOrder");
}
[Fact]
public void Apply_Parameters_ContainsComponentLotFilterParameter()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.Parameters.ShouldContainKey("p_ComponentLotFilter");
}
[Fact]
public void Priority_ReturnsExpectedValue()
{
// Assert
_handler.Priority.ShouldBe(30);
}
}
@@ -0,0 +1,155 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using SqlKata.Compilers;
using Xunit;
namespace JdeScoping.DataAccess.Tests.FilterHandlers;
/// <summary>
/// Unit tests for WorkOrderFilterHandler.
/// </summary>
public sealed class WorkOrderFilterHandlerTests
{
private readonly SqlServerCompiler _compiler = new();
private readonly WorkOrderFilterHandler _handler = new();
[Fact]
public void IsEnabled_WithWorkOrderFilters_ReturnsTrue()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsEnabled_WithEmptyWorkOrderFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter = []
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsEnabled_WithNullWorkOrderFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel();
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void Apply_GeneratedSql_ContainsMerge()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.ShouldNotBeNull();
result.SetupSql.ShouldNotBeEmpty();
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("MERGE #Temp_WO AS TARGET");
}
[Fact]
public void Apply_GeneratedSql_ContainsManuallySpecified()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("ManuallySpecified = 1");
}
[Fact]
public void Apply_GeneratedSql_ContainsSplitOrderLogic()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("SplitOrder");
allSql.ShouldContain("ParentWorkOrderNumber");
}
[Fact]
public void Apply_Parameters_ContainsWorkOrderFilterParameter()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
}
[Fact]
public void Priority_ReturnsExpectedValue()
{
// Assert
_handler.Priority.ShouldBe(10);
}
}
@@ -0,0 +1,674 @@
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests;
/// <summary>
/// Unit tests for JdeRepository.
/// </summary>
public class JdeRepositoryTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<JdeRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
public JdeRepositoryTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_logger = Substitute.For<ILogger<JdeRepository>>();
_options = Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
LotUsageTimeoutSeconds = 60,
ProductionSchema = "PRODDTA",
ArchiveSchema = "ARCDTAPD",
StageSchema = "JDESTAGE"
});
}
#region Constructor Tests
[Fact]
public void Constructor_NullConnectionFactory_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new JdeRepository(null!, _logger, _options))
.ParamName.ShouldBe("connectionFactory");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new JdeRepository(_connectionFactory, null!, _options))
.ParamName.ShouldBe("logger");
}
[Fact]
public void Constructor_NullOptions_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new JdeRepository(_connectionFactory, _logger, null!))
.ParamName.ShouldBe("options");
}
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Act
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Assert
repository.ShouldNotBeNull();
}
#endregion
#region Schema Replacement Tests - Work Orders
[Fact]
public async Task GetWorkOrdersAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrdersAsync())
{
}
});
ex.DataSource.ShouldBe("JDE");
}
[Fact]
public async Task GetWorkOrdersArchiveAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrdersArchiveAsync())
{
}
});
ex.DataSource.ShouldBe("JDE");
}
#endregion
#region Schema Replacement Tests - Work Order Steps
[Fact]
public async Task GetWorkOrderStepsAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderStepsAsync())
{
}
});
ex.DataSource.ShouldBe("JDE");
}
[Fact]
public async Task GetWorkOrderStepsArchiveAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderStepsArchiveAsync())
{
}
});
}
#endregion
#region Schema Replacement Tests - Work Order Times
[Fact]
public async Task GetWorkOrderTimesAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderTimesAsync())
{
}
});
}
[Fact]
public async Task GetWorkOrderTimesArchiveAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderTimesArchiveAsync())
{
}
});
}
#endregion
#region Schema Replacement Tests - Work Order Routings
[Fact]
public async Task GetWorkOrderRoutingsAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderRoutingsAsync())
{
}
});
}
#endregion
#region Schema Replacement Tests - Work Order Components
[Fact]
public async Task GetWorkOrderComponentsAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderComponentsAsync())
{
}
});
}
[Fact]
public async Task GetWorkOrderComponentsArchiveAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrderComponentsArchiveAsync())
{
}
});
}
#endregion
#region Schema Replacement Tests - Lots
[Fact]
public async Task GetLotsAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetLotsAsync())
{
}
});
}
#endregion
#region Schema Replacement Tests - Lot Usages
[Fact]
public async Task GetLotUsagesAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetLotUsagesAsync())
{
}
});
}
[Fact]
public async Task GetLotUsagesArchiveAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetLotUsagesArchiveAsync())
{
}
});
}
#endregion
#region JDE Stage Connection Tests - Lot Locations
[Fact]
public async Task GetLotLocationsAsync_UsesJdeStageConnection()
{
// Arrange
_connectionFactory.CreateJdeStageConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDEStage"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert - verify it uses Stage connection
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetLotLocationsAsync())
{
}
});
ex.DataSource.ShouldBe("JDEStage");
}
#endregion
#region Reference Data Tests
[Fact]
public async Task GetItemsAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetItemsAsync())
{
}
});
}
[Fact]
public async Task GetUsersAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetUsersAsync())
{
}
});
}
[Fact]
public async Task GetBranchesAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetBranchesAsync())
{
}
});
}
[Fact]
public async Task GetProfitCentersAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetProfitCentersAsync())
{
}
});
}
[Fact]
public async Task GetWorkCentersAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkCentersAsync())
{
}
});
}
[Fact]
public async Task GetStatusCodesAsync_UsesJdeStageConnection()
{
// Arrange
_connectionFactory.CreateJdeStageConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDEStage"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert - verify it uses Stage connection
var ex = await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetStatusCodesAsync())
{
}
});
ex.DataSource.ShouldBe("JDEStage");
}
[Fact]
public async Task GetFunctionCodesAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetFunctionCodesAsync())
{
}
});
}
[Fact]
public async Task GetOrgHierarchyAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetOrgHierarchyAsync())
{
}
});
}
[Fact]
public async Task GetRouteMastersAsync_ConnectionFails_ThrowsConnectionException()
{
// Arrange
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetRouteMastersAsync())
{
}
});
}
#endregion
#region Cancellation Tests
[Fact]
public async Task GetWorkOrdersAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrdersAsync(ct: cts.Token))
{
}
});
}
[Fact]
public async Task GetLotUsagesAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () =>
{
await foreach (var _ in repository.GetLotUsagesAsync(ct: cts.Token))
{
}
});
}
#endregion
#region Incremental Sync Tests
[Fact]
public async Task GetWorkOrdersAsync_WithLastUpdateDT_UsesFilteredQuery()
{
// Arrange
var lastUpdate = new DateTime(2024, 1, 15, 10, 30, 0);
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert - this just verifies the method accepts the parameter
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetWorkOrdersAsync(lastUpdate))
{
}
});
}
[Fact]
public async Task GetLotsAsync_WithLastUpdateDT_UsesFilteredQuery()
{
// Arrange
var lastUpdate = new DateTime(2024, 1, 15, 10, 30, 0);
_connectionFactory.CreateJdeConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "JDE"));
var repository = new JdeRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<ConnectionException>(
async () =>
{
await foreach (var _ in repository.GetLotsAsync(lastUpdate))
{
}
});
}
#endregion
#region Options Configuration Tests
[Fact]
public void Constructor_UsesConfiguredSchemas()
{
// Arrange
var customOptions = Options.Create(new DataAccessOptions
{
ProductionSchema = "CUSTOM_PROD",
ArchiveSchema = "CUSTOM_ARC",
StageSchema = "CUSTOM_STG"
});
// Act
var repository = new JdeRepository(_connectionFactory, _logger, customOptions);
// Assert
repository.ShouldNotBeNull();
// The schema values are internal, verified through integration tests
}
[Fact]
public void Constructor_UsesConfiguredTimeouts()
{
// Arrange
var customOptions = Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 120,
LotUsageTimeoutSeconds = 999999
});
// Act
var repository = new JdeRepository(_connectionFactory, _logger, customOptions);
// Assert
repository.ShouldNotBeNull();
// The timeout values are internal, verified through integration tests
}
#endregion
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.DataAccess\JdeScoping.DataAccess.csproj" />
<ProjectReference Include="..\..\src\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,638 @@
using JdeScoping.Core.Models;
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.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Repositories;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests;
/// <summary>
/// Unit tests for LotFinderRepository.
/// </summary>
public class LotFinderRepositoryTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<LotFinderRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
public LotFinderRepositoryTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_logger = Substitute.For<ILogger<LotFinderRepository>>();
_options = Options.Create(new DataAccessOptions
{
DefaultTimeoutSeconds = 30,
RebuildIndexTimeoutSeconds = 60
});
}
#region Constructor Tests
[Fact]
public void Constructor_NullConnectionFactory_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new LotFinderRepository(null!, _logger, _options))
.ParamName.ShouldBe("connectionFactory");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new LotFinderRepository(_connectionFactory, null!, _options))
.ParamName.ShouldBe("logger");
}
[Fact]
public void Constructor_NullOptions_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(
() => new LotFinderRepository(_connectionFactory, _logger, null!))
.ParamName.ShouldBe("options");
}
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Act
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Assert
repository.ShouldNotBeNull();
}
#endregion
#region RebuildIndicesAsync - Table Name Validation Tests
[Theory]
[InlineData("Branch")]
[InlineData("DataUpdate")]
[InlineData("FunctionCode")]
[InlineData("Item")]
[InlineData("JdeUser")]
[InlineData("Lot")]
[InlineData("LotLocation")]
[InlineData("LotUsage_Curr")]
[InlineData("LotUsage_Hist")]
[InlineData("MisData")]
[InlineData("OrgHierarchy")]
[InlineData("ProfitCenter")]
[InlineData("RouteMaster")]
[InlineData("Search")]
[InlineData("StatusCode")]
[InlineData("WorkCenter")]
[InlineData("WorkOrder_Curr")]
[InlineData("WorkOrder_Hist")]
[InlineData("WorkOrderComponent_Curr")]
[InlineData("WorkOrderComponent_Hist")]
[InlineData("WorkOrderRouting")]
[InlineData("WorkOrderStep_Curr")]
[InlineData("WorkOrderStep_Hist")]
[InlineData("WorkOrderTime_Curr")]
[InlineData("WorkOrderTime_Hist")]
public async Task RebuildIndicesAsync_ValidTableName_DoesNotThrowArgumentException(string tableName)
{
// Arrange - expect connection exception since we have no real connection
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert - should throw QueryException (wrapped ConnectionException), not ArgumentException
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.RebuildIndicesAsync(tableName));
ex.QueryName.ShouldBe("SQL_REBUILD_INDICES");
}
[Theory]
[InlineData("InvalidTable")]
[InlineData("DropTable")]
[InlineData("Users")]
[InlineData("sys.tables")]
[InlineData("'; DROP TABLE Users; --")]
[InlineData("WorkOrder")]
[InlineData("branch")] // Case-insensitive should still work
public async Task RebuildIndicesAsync_InvalidTableName_ThrowsArgumentException(string tableName)
{
// Arrange
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
// Note: "branch" is case-insensitive match for "Branch", so it should NOT throw
if (tableName.Equals("branch", StringComparison.OrdinalIgnoreCase))
{
// Case-insensitive match - will try to connect and throw QueryException
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection", "LotFinderDB"));
await Should.ThrowAsync<QueryException>(
async () => await repository.RebuildIndicesAsync(tableName));
}
else
{
var ex = await Should.ThrowAsync<ArgumentException>(
async () => await repository.RebuildIndicesAsync(tableName));
ex.ParamName.ShouldBe("tableName");
ex.Message.ShouldContain($"Invalid table name: {tableName}");
}
}
#endregion
#region TruncateTableAsync - Table Name Validation Tests
[Theory]
[InlineData("Branch")]
[InlineData("Item")]
[InlineData("WorkOrder_Curr")]
public async Task TruncateTableAsync_ValidTableName_DoesNotThrowArgumentException(string tableName)
{
// Arrange - expect connection exception since we have no real connection
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert - should throw QueryException (wrapped ConnectionException), not ArgumentException
await Should.ThrowAsync<QueryException>(
async () => await repository.TruncateTableAsync(tableName));
}
[Theory]
[InlineData("InvalidTable")]
[InlineData("'; DELETE FROM Users; --")]
public async Task TruncateTableAsync_InvalidTableName_ThrowsArgumentException(string tableName)
{
// Arrange
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<ArgumentException>(
async () => await repository.TruncateTableAsync(tableName));
ex.ParamName.ShouldBe("tableName");
ex.Message.ShouldContain($"Invalid table name: {tableName}");
}
#endregion
#region BulkInsertAsync - Table Name Validation Tests
[Theory]
[InlineData("Branch")]
[InlineData("Item")]
public async Task BulkInsertAsync_ValidTableName_DoesNotThrowArgumentException(string tableName)
{
// Arrange - expect connection exception since we have no real connection
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
var records = new List<Item>();
// Act & Assert - should throw QueryException (wrapped ConnectionException), not ArgumentException
await Should.ThrowAsync<QueryException>(
async () => await repository.BulkInsertAsync(tableName, records));
}
[Theory]
[InlineData("InvalidTable")]
[InlineData("'; TRUNCATE TABLE Users; --")]
public async Task BulkInsertAsync_InvalidTableName_ThrowsArgumentException(string tableName)
{
// Arrange
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
var records = new List<Item>();
// Act & Assert
var ex = await Should.ThrowAsync<ArgumentException>(
async () => await repository.BulkInsertAsync(tableName, records));
ex.ParamName.ShouldBe("tableName");
ex.Message.ShouldContain($"Invalid table name: {tableName}");
}
#endregion
#region Connection Exception Handling Tests
[Fact]
public async Task GetUserSearchesAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetUserSearchesAsync("testuser"));
ex.QueryName.ShouldBe("SQL_GET_USER_SEARCHES");
ex.Repository.ShouldBe("LotFinderRepository");
}
[Fact]
public async Task GetQueuedSearchesAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetQueuedSearchesAsync());
ex.QueryName.ShouldBe("SQL_GET_QUEUED_SEARCHES");
}
[Fact]
public async Task GetSearchAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetSearchAsync(1));
ex.QueryName.ShouldBe("SQL_GET_SEARCH");
}
[Fact]
public async Task GetSearchResultsAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetSearchResultsAsync(1));
ex.QueryName.ShouldBe("SQL_GET_SEARCH_RESULTS");
}
[Fact]
public async Task SubmitSearchAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
var search = new Search { UserName = "testuser", Name = "Test Search" };
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.SubmitSearchAsync(search));
ex.QueryName.ShouldBe("SubmitSearch");
}
[Fact]
public async Task UpdateSearchStatusAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.UpdateSearchStatusAsync(1, SearchStatus.Running));
ex.QueryName.ShouldBe("SQL_UPDATE_SEARCH_STATUS");
}
[Fact]
public async Task UpdateSearchResultsAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.UpdateSearchResultsAsync(1, [1, 2, 3]));
ex.QueryName.ShouldBe("SQL_UPDATE_SEARCH_RESULTS");
}
#endregion
#region Reference Data Lookup Exception Handling Tests
[Fact]
public async Task SearchItemsAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.SearchItemsAsync("test"));
ex.QueryName.ShouldBe("SQL_SEARCH_ITEMS");
}
[Fact]
public async Task LookupItemsAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupItemsAsync(["ITEM001"]));
ex.QueryName.ShouldBe("SQL_LOOKUP_ITEMS");
}
[Fact]
public async Task LookupWorkordersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupWorkordersAsync([12345]));
ex.QueryName.ShouldBe("SQL_LOOKUP_WORKORDERS");
}
[Fact]
public async Task SearchWorkCentersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.SearchWorkCentersAsync("test"));
ex.QueryName.ShouldBe("SQL_SEARCH_WORK_CENTERS");
}
[Fact]
public async Task LookupWorkCentersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupWorkCentersAsync(["WC01"]));
ex.QueryName.ShouldBe("SQL_LOOKUP_WORK_CENTERS");
}
[Fact]
public async Task SearchProfitCentersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.SearchProfitCentersAsync("test"));
ex.QueryName.ShouldBe("SQL_SEARCH_PROFIT_CENTERS");
}
[Fact]
public async Task LookupProfitCentersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupProfitCentersAsync(["PC01"]));
ex.QueryName.ShouldBe("SQL_LOOKUP_PROFIT_CENTERS");
}
[Fact]
public async Task SearchUsersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.SearchUsersAsync("test"));
ex.QueryName.ShouldBe("SQL_SEARCH_USERS");
}
[Fact]
public async Task LookupUsersAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupUsersAsync(["USER01"]));
ex.QueryName.ShouldBe("SQL_LOOKUP_USERS");
}
[Fact]
public async Task LookupLotsAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
var lots = new List<LotViewModel> { new LotViewModel { LotNumber = "LOT001", ItemNumber = "ITEM001" } };
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.LookupLotsAsync(lots));
ex.QueryName.ShouldBe("SQL_LOOKUP_LOTS");
}
#endregion
#region Data Sync Exception Handling Tests
[Fact]
public async Task GetLastDataUpdatesAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetLastDataUpdatesAsync());
ex.QueryName.ShouldBe("SQL_GET_LAST_DATA_UPDATES");
}
[Fact]
public async Task GetTableSpecAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.GetTableSpecAsync("Item"));
ex.QueryName.ShouldBe("SQL_GET_TABLE_COLUMNS");
}
[Fact]
public async Task PostProcessMisDataAsync_ConnectionFails_ThrowsQueryException()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
var ex = await Should.ThrowAsync<QueryException>(
async () => await repository.PostProcessMisDataAsync());
ex.QueryName.ShouldBe("SQL_POSTPROCESS_MISDATA");
}
#endregion
#region Cancellation Tests
[Fact]
public async Task GetUserSearchesAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () => await repository.GetUserSearchesAsync("testuser", cts.Token));
}
[Fact]
public async Task GetQueuedSearchesAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException(cts.Token));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
async () => await repository.GetQueuedSearchesAsync(cts.Token));
}
#endregion
#region Logging Tests
[Fact]
public async Task GetUserSearchesAsync_ConnectionFails_LogsError()
{
// Arrange
_connectionFactory.CreateLotFinderConnectionAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new ConnectionException("Test connection error", "LotFinderDB"));
var repository = new LotFinderRepository(_connectionFactory, _logger, _options);
// Act
try
{
await repository.GetUserSearchesAsync("testuser");
}
catch (QueryException)
{
// Expected
}
// Assert - verify logging was called
_logger.ReceivedWithAnyArgs(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
#endregion
}
@@ -0,0 +1,279 @@
using JdeScoping.DataAccess.Models.Results;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests.Models;
/// <summary>
/// Unit tests for SearchResult InclusionReason property.
/// </summary>
public sealed class SearchResultTests
{
#region ManuallySpecified Priority Tests
[Fact]
public void InclusionReason_WhenManuallySpecified_ReturnsManuallySpecified()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = true,
Flagged = false,
Cardex = false,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ManuallySpecified");
}
[Fact]
public void InclusionReason_ManuallySpecified_TakesPriorityOverFlagged()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = true,
Flagged = true,
Cardex = false,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ManuallySpecified");
}
[Fact]
public void InclusionReason_ManuallySpecified_TakesPriorityOverCARDEX()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = true,
Flagged = false,
Cardex = true,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ManuallySpecified");
}
[Fact]
public void InclusionReason_ManuallySpecified_TakesPriorityOverAllOtherFlags()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = true,
Flagged = true,
Cardex = true,
PartsList = true,
SplitOrder = true
};
// Act & Assert
result.InclusionReason.ShouldBe("ManuallySpecified");
}
#endregion
#region Flagged Tests
[Fact]
public void InclusionReason_WhenFlagged_ReturnsFlagged()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = true,
Cardex = false,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("Flagged");
}
[Fact]
public void InclusionReason_Flagged_TakesPriorityOverCARDEX()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = true,
Cardex = true,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("Flagged");
}
#endregion
#region CARDEX and PartsList Tests
[Fact]
public void InclusionReason_WhenCARDEXAndPartsList_ReturnsCombinedMessage()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = true,
PartsList = true,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ComponentUsage (CARDEX + Parts List)");
}
[Fact]
public void InclusionReason_WhenOnlyCARDEX_ReturnsCARDEXMessage()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = true,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ComponentUsage (CARDEX)");
}
[Fact]
public void InclusionReason_WhenOnlyPartsList_ReturnsPartsListMessage()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = false,
PartsList = true,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("ComponentUsage (Parts List)");
}
#endregion
#region SplitOrder Tests
[Fact]
public void InclusionReason_WhenSplitOrder_ReturnsSplitOrderMessage()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = false,
PartsList = false,
SplitOrder = true
};
// Act & Assert
result.InclusionReason.ShouldBe("Split order");
}
[Fact]
public void InclusionReason_CARDEXAndPartsList_TakePriorityOverSplitOrder()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = true,
PartsList = false,
SplitOrder = true
};
// Act & Assert
result.InclusionReason.ShouldBe("ComponentUsage (CARDEX)");
}
#endregion
#region Unknown Fallback Tests
[Fact]
public void InclusionReason_WhenNoFlagsSet_ReturnsUnknown()
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = false,
Flagged = false,
Cardex = false,
PartsList = false,
SplitOrder = false
};
// Act & Assert
result.InclusionReason.ShouldBe("UNKNOWN");
}
[Fact]
public void InclusionReason_DefaultRecord_ReturnsUnknown()
{
// Arrange
var result = new SearchResult();
// Act & Assert
result.InclusionReason.ShouldBe("UNKNOWN");
}
#endregion
#region Priority Order Verification
[Theory]
[InlineData(true, true, true, true, true, "ManuallySpecified")]
[InlineData(false, true, true, true, true, "Flagged")]
[InlineData(false, false, true, true, true, "ComponentUsage (CARDEX + Parts List)")]
[InlineData(false, false, true, false, true, "ComponentUsage (CARDEX)")]
[InlineData(false, false, false, true, true, "ComponentUsage (Parts List)")]
[InlineData(false, false, false, false, true, "Split order")]
[InlineData(false, false, false, false, false, "UNKNOWN")]
public void InclusionReason_FollowsCorrectPriorityOrder(
bool manuallySpecified,
bool flagged,
bool cardex,
bool partsList,
bool splitOrder,
string expectedReason)
{
// Arrange
var result = new SearchResult
{
ManuallySpecified = manuallySpecified,
Flagged = flagged,
Cardex = cardex,
PartsList = partsList,
SplitOrder = splitOrder
};
// Act & Assert
result.InclusionReason.ShouldBe(expectedReason);
}
#endregion
}
@@ -0,0 +1,238 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using JdeScoping.DataAccess.QueryBuilders;
using NSubstitute;
using Shouldly;
using SqlKata.Compilers;
using Xunit;
namespace JdeScoping.DataAccess.Tests.QueryBuilders;
/// <summary>
/// Unit tests for SqlKataSearchQueryBuilder.
/// </summary>
public sealed class SqlKataSearchQueryBuilderTests
{
private readonly SqlServerCompiler _compiler = new();
[Fact]
public void BuildSearchQuery_WithEmptyFilters_ProducesMinimalQuery()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty();
result.TempTableSetupSql.ShouldNotBeEmpty();
// Should contain temp table creation
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("CREATE TABLE");
}
[Fact]
public void BuildSearchQuery_WithEmptyFilters_ResultSqlContainsSelect()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
result.Sql.ShouldContain("SELECT");
}
[Fact]
public void BuildSearchQuery_WithSingleFilter_ProducesCorrectStructure()
{
// Arrange
var workOrderHandler = new WorkOrderFilterHandler();
var handlers = new IFilterHandler[] { workOrderHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 }
]
};
// Act
var result = builder.BuildSearchQuery(model);
// Assert
result.ShouldNotBeNull();
result.TempTableSetupSql.ShouldNotBeEmpty();
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
var setupSql = string.Join("\n", result.TempTableSetupSql);
// Should have temp table creation and work order merge
setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("MERGE");
}
[Fact]
public void BuildSearchQuery_WithMultipleFilters_CombinesCorrectly()
{
// Arrange
var workOrderHandler = new WorkOrderFilterHandler();
var itemNumberHandler = new ItemNumberFilterHandler();
var handlers = new IFilterHandler[] { workOrderHandler, itemNumberHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 }
],
ItemNumberFilter =
[
new ItemNumberFilterEntry { ItemNumber = "ABC123" }
]
};
// Act
var result = builder.BuildSearchQuery(model);
// Assert
result.ShouldNotBeNull();
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
result.Parameters.ShouldContainKey("p_ItemNumberFilter");
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("#Temp_WO");
setupSql.ShouldContain("#P_ItemNumbers");
}
[Fact]
public void BuildSearchQuery_HandlersAreAppliedInPriorityOrder()
{
// Arrange
var lowPriorityHandler = Substitute.For<IFilterHandler>();
lowPriorityHandler.Priority.Returns(100);
lowPriorityHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
lowPriorityHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- LOW PRIORITY SQL"], new Dictionary<string, object>()));
var highPriorityHandler = Substitute.For<IFilterHandler>();
highPriorityHandler.Priority.Returns(1);
highPriorityHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
highPriorityHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- HIGH PRIORITY SQL"], new Dictionary<string, object>()));
// Pass handlers in reverse priority order to verify sorting
var handlers = new[] { lowPriorityHandler, highPriorityHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
var highIndex = setupSql.IndexOf("-- HIGH PRIORITY SQL", StringComparison.Ordinal);
var lowIndex = setupSql.IndexOf("-- LOW PRIORITY SQL", StringComparison.Ordinal);
highIndex.ShouldBeGreaterThan(-1);
lowIndex.ShouldBeGreaterThan(-1);
highIndex.ShouldBeLessThan(lowIndex);
}
[Fact]
public void BuildSearchQuery_DisabledHandlersAreSkipped()
{
// Arrange
var enabledHandler = Substitute.For<IFilterHandler>();
enabledHandler.Priority.Returns(1);
enabledHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(true);
enabledHandler.Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>())
.Returns(new FilterResult(["-- ENABLED"], new Dictionary<string, object>()));
var disabledHandler = Substitute.For<IFilterHandler>();
disabledHandler.Priority.Returns(2);
disabledHandler.IsEnabled(Arg.Any<SearchModel>()).Returns(false);
var handlers = new[] { enabledHandler, disabledHandler };
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildSearchQuery(model);
// Assert
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("-- ENABLED");
// Apply should never be called on disabled handler
disabledHandler.DidNotReceive().Apply(Arg.Any<SearchModel>(), Arg.Any<SqlServerCompiler>());
}
[Fact]
public void BuildSearchQuery_WithTimespanFilter_IncludesStepFlagging()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel
{
MinimumDt = DateTime.Now.AddDays(-30),
MaximumDt = DateTime.Now
};
// Act
var result = builder.BuildSearchQuery(model);
// Assert
// When ShouldSearchSteps returns true, step flagging query is added
var setupSql = string.Join("\n", result.TempTableSetupSql);
setupSql.ShouldContain("LU_WO");
setupSql.ShouldContain("Flagged");
}
[Fact]
public void BuildMisQuery_ReturnsValidResult()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildMisQuery(model);
// Assert
result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty();
result.Sql.ShouldContain("#TempMisData");
}
[Fact]
public void BuildMisNonMatchQuery_ReturnsValidResult()
{
// Arrange
var handlers = Array.Empty<IFilterHandler>();
var builder = new SqlKataSearchQueryBuilder(_compiler, handlers);
var model = new SearchModel();
// Act
var result = builder.BuildMisNonMatchQuery(model);
// Assert
result.ShouldNotBeNull();
result.Sql.ShouldNotBeNullOrEmpty();
result.Sql.ShouldContain("WasJobStepAdded");
result.Sql.ShouldContain("MatchedJobStepNumber");
}
}