diff --git a/NEW/tests/JdeScoping.Database.Tests/Functions/ComplexTableFunctionTests.cs b/NEW/tests/JdeScoping.Database.Tests/Functions/ComplexTableFunctionTests.cs new file mode 100644 index 0000000..ba16ddb --- /dev/null +++ b/NEW/tests/JdeScoping.Database.Tests/Functions/ComplexTableFunctionTests.cs @@ -0,0 +1,514 @@ +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 +}