Files
Joseph Doherty 1e23616638 refactor(tests): migrate Database.Tests from FluentAssertions to Shouldly
Replace FluentAssertions with Shouldly across all 6 test files (94 tests).
Add ShouldlyExtensions for BeCloseTo and BeEquivalentTo patterns.
2026-01-29 14:40:18 -05:00

515 lines
21 KiB
C#

using Dapper;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using JdeScoping.Database.Tests.Infrastructure;
using Shouldly;
namespace JdeScoping.Database.Tests.Functions;
/// <summary>
/// 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)
/// </summary>
[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.Count.ShouldBe(3);
resultList.ShouldContain(x => x.LotNumber == "LOT001" && x.ItemNumber == "ITEM001");
resultList.ShouldContain(x => x.LotNumber == "LOT002" && x.ItemNumber == "ITEM002");
resultList.ShouldContain(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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.Count.ShouldBe(3);
resultList.ShouldContain(x => x.LotNumber == "LOT001" && x.ItemNumber == null);
resultList.ShouldContain(x => x.LotNumber == null && x.ItemNumber == "ITEM002");
resultList.ShouldContain(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.Count.ShouldBe(2);
resultList.ShouldContain(x => x.LotNumber == "LOT-001" && x.ItemNumber == "ITEM_001");
resultList.ShouldContain(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.Count.ShouldBe(3);
resultList.ShouldContain(x => x.ItemNumber == "ITEM001" && x.OperationNumber == "10" && x.MisNumber == "MIS001" && x.MisRevision == "A");
resultList.ShouldContain(x => x.ItemNumber == "ITEM002" && x.OperationNumber == "20" && x.MisNumber == "MIS002" && x.MisRevision == "B");
resultList.ShouldContain(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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.ShouldBeEmpty();
}
[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.Count.ShouldBe(3);
resultList.ShouldContain(x => x.ItemNumber == "ITEM001" && x.OperationNumber == "10" && x.MisNumber == null && x.MisRevision == null);
resultList.ShouldContain(x => x.ItemNumber == null && x.OperationNumber == null && x.MisNumber == "MIS002" && x.MisRevision == "B");
resultList.ShouldContain(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.Count.ShouldBe(1);
var result = resultList.First();
result.ItemNumber.ShouldBe("ITEM001");
result.OperationNumber.ShouldBe("10");
result.MisNumber.ShouldBeNull();
result.MisRevision.ShouldBeNull();
}
[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.Count.ShouldBe(1);
var result = resultList.First();
result.ItemNumber.ShouldBe("ITEM-001");
result.OperationNumber.ShouldBe("10A");
result.MisNumber.ShouldBe("MIS.001");
result.MisRevision.ShouldBe("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.Count().ShouldBe(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.Count().ShouldBe(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.Count.ShouldBe(1);
resultList.First().LotNumber.ShouldBeNull();
resultList.First().ItemNumber.ShouldBeNull();
}
[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.Count.ShouldBe(1);
var result = resultList.First();
result.ItemNumber.ShouldBeNull();
result.OperationNumber.ShouldBeNull();
result.MisNumber.ShouldBeNull();
result.MisRevision.ShouldBeNull();
}
[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.Count.ShouldBe(1);
resultList.First().LotNumber.Length.ShouldBe(30);
resultList.First().ItemNumber.Length.ShouldBe(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.Count.ShouldBe(1);
var result = resultList.First();
result.ItemNumber.Length.ShouldBe(128);
result.OperationNumber.Length.ShouldBe(10);
result.MisNumber.Length.ShouldBe(10);
result.MisRevision.Length.ShouldBe(10);
}
#endregion
}