using Dapper; using FluentAssertions; using JdeScoping.Core.Models.Search; using JdeScoping.Core.ViewModels; using JdeScoping.Database.Tests.Infrastructure; namespace JdeScoping.Database.Tests.Functions; /// /// Tests for complex table extraction functions that return multi-column result sets. /// These inline TVFs extract object arrays from Search.Criteria JSON: /// - fn_GetSearchComponentLots: returns (LotNumber, ItemNumber) /// - fn_GetSearchPartOperations: returns (ItemNumber, OperationNumber, MisNumber, MisRevision) /// [Collection("DatabaseTests")] public class ComplexTableFunctionTests : DatabaseTestBase { #region fn_GetSearchComponentLots Tests [Fact] public async Task fn_GetSearchComponentLots_ValidArray_ReturnsAllRowsWithAllColumns() { // Arrange var criteria = new SearchCriteria { ComponentLotNumbers = [ new LotViewModel { LotNumber = "LOT001", ItemNumber = "ITEM001" }, new LotViewModel { LotNumber = "LOT002", ItemNumber = "ITEM002" }, new LotViewModel { LotNumber = "LOT003", ItemNumber = "ITEM003" } ] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(3); resultList.Should().Contain(x => x.LotNumber == "LOT001" && x.ItemNumber == "ITEM001"); resultList.Should().Contain(x => x.LotNumber == "LOT002" && x.ItemNumber == "ITEM002"); resultList.Should().Contain(x => x.LotNumber == "LOT003" && x.ItemNumber == "ITEM003"); } [Fact] public async Task fn_GetSearchComponentLots_EmptyArray_ReturnsEmpty() { // Arrange var criteria = new SearchCriteria { ComponentLotNumbers = [] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchComponentLots_MissingProperty_ReturnsEmpty() { // Arrange - criteria without ComponentLotNumbers property var searchId = await InsertTestSearchWithRawCriteriaAsync("{\"MinimumDt\":null}"); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchComponentLots_SearchNotFound_ReturnsEmpty() { // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = 99999 }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchComponentLots_NullCriteria_ReturnsEmpty() { // Arrange var searchId = await InsertTestSearchWithRawCriteriaAsync(null); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchComponentLots_InvalidJson_ReturnsEmpty() { // Arrange var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json"); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchComponentLots_PartialObjects_UsesNullForMissingColumns() { // Arrange - JSON with objects missing some properties var searchId = await InsertTestSearchWithRawCriteriaAsync( "{\"ComponentLotNumbers\":[" + "{\"LotNumber\":\"LOT001\"}," + // Missing ItemNumber "{\"ItemNumber\":\"ITEM002\"}," + // Missing LotNumber "{\"LotNumber\":\"LOT003\",\"ItemNumber\":\"ITEM003\"}" + // Complete "]}"); // Act var results = await Connection.QueryAsync<(string? LotNumber, string? ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(3); resultList.Should().Contain(x => x.LotNumber == "LOT001" && x.ItemNumber == null); resultList.Should().Contain(x => x.LotNumber == null && x.ItemNumber == "ITEM002"); resultList.Should().Contain(x => x.LotNumber == "LOT003" && x.ItemNumber == "ITEM003"); } [Fact] public async Task fn_GetSearchComponentLots_SpecialCharacters_ReturnsCorrectly() { // Arrange var criteria = new SearchCriteria { ComponentLotNumbers = [ new LotViewModel { LotNumber = "LOT-001", ItemNumber = "ITEM_001" }, new LotViewModel { LotNumber = "LOT.002", ItemNumber = "ITEM/002" } ] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(2); resultList.Should().Contain(x => x.LotNumber == "LOT-001" && x.ItemNumber == "ITEM_001"); resultList.Should().Contain(x => x.LotNumber == "LOT.002" && x.ItemNumber == "ITEM/002"); } #endregion #region fn_GetSearchPartOperations Tests [Fact] public async Task fn_GetSearchPartOperations_ValidArray_ReturnsAllRowsWithAllColumns() { // Arrange var criteria = new SearchCriteria { PartOperations = [ new PartOperationViewModel { ItemNumber = "ITEM001", OperationNumber = "10", MisNumber = "MIS001", MisRevision = "A" }, new PartOperationViewModel { ItemNumber = "ITEM002", OperationNumber = "20", MisNumber = "MIS002", MisRevision = "B" }, new PartOperationViewModel { ItemNumber = "ITEM003", OperationNumber = "30", MisNumber = "MIS003", MisRevision = "C" } ] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(3); resultList.Should().Contain(x => x.ItemNumber == "ITEM001" && x.OperationNumber == "10" && x.MisNumber == "MIS001" && x.MisRevision == "A"); resultList.Should().Contain(x => x.ItemNumber == "ITEM002" && x.OperationNumber == "20" && x.MisNumber == "MIS002" && x.MisRevision == "B"); resultList.Should().Contain(x => x.ItemNumber == "ITEM003" && x.OperationNumber == "30" && x.MisNumber == "MIS003" && x.MisRevision == "C"); } [Fact] public async Task fn_GetSearchPartOperations_EmptyArray_ReturnsEmpty() { // Arrange var criteria = new SearchCriteria { PartOperations = [] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchPartOperations_MissingProperty_ReturnsEmpty() { // Arrange - criteria without PartOperations property var searchId = await InsertTestSearchWithRawCriteriaAsync("{\"MinimumDt\":null}"); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchPartOperations_SearchNotFound_ReturnsEmpty() { // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = 99999 }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchPartOperations_NullCriteria_ReturnsEmpty() { // Arrange var searchId = await InsertTestSearchWithRawCriteriaAsync(null); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchPartOperations_InvalidJson_ReturnsEmpty() { // Arrange var searchId = await InsertTestSearchWithRawCriteriaAsync("not valid json"); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert results.Should().BeEmpty(); } [Fact] public async Task fn_GetSearchPartOperations_PartialObjects_UsesNullForMissingColumns() { // Arrange - JSON with objects missing some properties var searchId = await InsertTestSearchWithRawCriteriaAsync( "{\"PartOperations\":[" + "{\"ItemNumber\":\"ITEM001\",\"OperationNumber\":\"10\"}," + // Missing MisNumber and MisRevision "{\"MisNumber\":\"MIS002\",\"MisRevision\":\"B\"}," + // Missing ItemNumber and OperationNumber "{\"ItemNumber\":\"ITEM003\",\"OperationNumber\":\"30\",\"MisNumber\":\"MIS003\",\"MisRevision\":\"C\"}" + // Complete "]}"); // Act var results = await Connection.QueryAsync<(string? ItemNumber, string? OperationNumber, string? MisNumber, string? MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(3); resultList.Should().Contain(x => x.ItemNumber == "ITEM001" && x.OperationNumber == "10" && x.MisNumber == null && x.MisRevision == null); resultList.Should().Contain(x => x.ItemNumber == null && x.OperationNumber == null && x.MisNumber == "MIS002" && x.MisRevision == "B"); resultList.Should().Contain(x => x.ItemNumber == "ITEM003" && x.OperationNumber == "30" && x.MisNumber == "MIS003" && x.MisRevision == "C"); } [Fact] public async Task fn_GetSearchPartOperations_NullOptionalFields_ReturnsWithNulls() { // Arrange - using C# model with empty strings (which serialize to "") // The SQL function handles nulls/missing in JSON, so we test explicit null in JSON var searchId = await InsertTestSearchWithRawCriteriaAsync( "{\"PartOperations\":[" + "{\"ItemNumber\":\"ITEM001\",\"OperationNumber\":\"10\",\"MisNumber\":null,\"MisRevision\":null}" + "]}"); // Act var results = await Connection.QueryAsync<(string? ItemNumber, string? OperationNumber, string? MisNumber, string? MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); var result = resultList.First(); result.ItemNumber.Should().Be("ITEM001"); result.OperationNumber.Should().Be("10"); result.MisNumber.Should().BeNull(); result.MisRevision.Should().BeNull(); } [Fact] public async Task fn_GetSearchPartOperations_SpecialCharacters_ReturnsCorrectly() { // Arrange var criteria = new SearchCriteria { PartOperations = [ new PartOperationViewModel { ItemNumber = "ITEM-001", OperationNumber = "10A", MisNumber = "MIS.001", MisRevision = "A-1" } ] }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); var result = resultList.First(); result.ItemNumber.Should().Be("ITEM-001"); result.OperationNumber.Should().Be("10A"); result.MisNumber.Should().Be("MIS.001"); result.MisRevision.Should().Be("A-1"); } #endregion #region Additional Edge Case Tests [Fact] public async Task fn_GetSearchComponentLots_LargeArray_ReturnsAll() { // Arrange var lots = Enumerable.Range(1, 100) .Select(i => new LotViewModel { LotNumber = $"LOT{i:D4}", ItemNumber = $"ITEM{i:D4}" }) .ToList(); var criteria = new SearchCriteria { ComponentLotNumbers = lots }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert results.Should().HaveCount(100); } [Fact] public async Task fn_GetSearchPartOperations_LargeArray_ReturnsAll() { // Arrange var operations = Enumerable.Range(1, 100) .Select(i => new PartOperationViewModel { ItemNumber = $"ITEM{i:D4}", OperationNumber = $"{i * 10}", MisNumber = $"MIS{i:D4}", MisRevision = $"R{i}" }) .ToList(); var criteria = new SearchCriteria { PartOperations = operations }; var searchId = await InsertTestSearchAsync(criteria); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert results.Should().HaveCount(100); } [Fact] public async Task fn_GetSearchComponentLots_EmptyObject_ReturnsRowWithNulls() { // Arrange - JSON with empty object in array var searchId = await InsertTestSearchWithRawCriteriaAsync( "{\"ComponentLotNumbers\":[{}]}"); // Act var results = await Connection.QueryAsync<(string? LotNumber, string? ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); resultList.First().LotNumber.Should().BeNull(); resultList.First().ItemNumber.Should().BeNull(); } [Fact] public async Task fn_GetSearchPartOperations_EmptyObject_ReturnsRowWithNulls() { // Arrange - JSON with empty object in array var searchId = await InsertTestSearchWithRawCriteriaAsync( "{\"PartOperations\":[{}]}"); // Act var results = await Connection.QueryAsync<(string? ItemNumber, string? OperationNumber, string? MisNumber, string? MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); var result = resultList.First(); result.ItemNumber.Should().BeNull(); result.OperationNumber.Should().BeNull(); result.MisNumber.Should().BeNull(); result.MisRevision.Should().BeNull(); } [Fact] public async Task fn_GetSearchComponentLots_LongValues_Truncated() { // Arrange - LotNumber is VARCHAR(30), ItemNumber is VARCHAR(128) var longLotNumber = new string('L', 50); // Will be truncated to 30 var longItemNumber = new string('I', 200); // Will be truncated to 128 var searchId = await InsertTestSearchWithRawCriteriaAsync( $"{{\"ComponentLotNumbers\":[{{\"LotNumber\":\"{longLotNumber}\",\"ItemNumber\":\"{longItemNumber}\"}}]}}"); // Act var results = await Connection.QueryAsync<(string LotNumber, string ItemNumber)>( "SELECT LotNumber, ItemNumber FROM dbo.fn_GetSearchComponentLots(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); resultList.First().LotNumber.Should().HaveLength(30); resultList.First().ItemNumber.Should().HaveLength(128); } [Fact] public async Task fn_GetSearchPartOperations_LongValues_Truncated() { // Arrange - ItemNumber VARCHAR(128), OperationNumber/MisNumber/MisRevision VARCHAR(10) var longItemNumber = new string('I', 200); // Truncated to 128 var longOperationNumber = new string('O', 20); // Truncated to 10 var longMisNumber = new string('M', 20); // Truncated to 10 var longMisRevision = new string('R', 20); // Truncated to 10 var searchId = await InsertTestSearchWithRawCriteriaAsync( $"{{\"PartOperations\":[{{\"ItemNumber\":\"{longItemNumber}\",\"OperationNumber\":\"{longOperationNumber}\",\"MisNumber\":\"{longMisNumber}\",\"MisRevision\":\"{longMisRevision}\"}}]}}"); // Act var results = await Connection.QueryAsync<(string ItemNumber, string OperationNumber, string MisNumber, string MisRevision)>( "SELECT ItemNumber, OperationNumber, MisNumber, MisRevision FROM dbo.fn_GetSearchPartOperations(@SearchId)", new { SearchId = searchId }); // Assert var resultList = results.ToList(); resultList.Should().HaveCount(1); var result = resultList.First(); result.ItemNumber.Should().HaveLength(128); result.OperationNumber.Should().HaveLength(10); result.MisNumber.Should().HaveLength(10); result.MisRevision.Should().HaveLength(10); } #endregion }