using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FrameworkBLL.DrillDown; using FrameworkDAL.CustomCode.DrillDown; using FrameworkDAL.DTO.DrillDown; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Validation; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace FrameworkTests; /// /// Regression coverage for a real, pre-existing bug found while building Metadata Promotion's /// DrillDown adapter: SaveDrillDown minted detail-row IDs by incrementing the PARENT's own /// DrillDownId in a loop (`child.DrillDownDetailId = DrillDownDTO.DrillDownId++`), corrupting the /// parent's own primary key by +N (N = detail row count) before it was even saved, and leaving /// every child's own DrillDownId FK wrong. Fixed to mint detail IDs via a real AutoNumber /// reservation (entity code "DRILLDOWNDETAIL") instead — this locks that fix in place. /// /// AutoNumber.GetAutoNumber (SQL Server path) calls IQueryExecutor.ExecuteScalarAsync<long> /// with an UPDATE...OUTPUT DELETED.AUTOID statement, returning previousAutoId directly (not a /// post-incremented value) — StartNumber = previousAutoId + 1 + LoginDTO.ServerConfigOffset(0 here). /// public class DrillDownBLLTests { private static LoginDTO Login(int userId = 7) => new() { ClientId = -1, UserId = userId }; private static AutoNumber RealAutoNumberReserving(Queue previousAutoIdsInCallOrder) { var qe = new Mock(); qe.Setup(x => x.ExecuteScalarAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(() => previousAutoIdsInCallOrder.Dequeue()); return new AutoNumber(qe.Object, Mock.Of>( o => o.Value == new Gb5SystemDTO { DataBaseType = 0 })); } [Fact] public async Task SaveDrillDown_NewWithDetailRows_ParentIdIsNeverMutatedByDetailAssignment() { // First AutoNumber call reserves the parent DrillDownId (previousAutoId=500 -> StartNumber // 501) — second call reserves 3 detail IDs at once (previousAutoId=900 -> StartNumber 901). var autoNumber = RealAutoNumberReserving(new Queue(new long[] { 500, 900 })); var dal = new Mock(); DrillDownDTO? captured = null; dal.Setup(d => d.SaveDrillDown(It.IsAny(), It.IsAny())) .Callback((dto, _) => captured = dto) .ReturnsAsync(0); var bll = new DrillDownBLL(dal.Object, autoNumber, Mock.Of()); var dto = new DrillDownDTO { DrillDownCode = "TEST_DD", DrillDownDescription = "Test", DrillDownDetailArray = new List { new() { DrillDownDetailSlNo = 1 }, new() { DrillDownDetailSlNo = 2 }, new() { DrillDownDetailSlNo = 3 } } }; await bll.SaveDrillDown(dto, Login()); // The bug: DrillDownId got incremented once per detail row (a "+3" corruption here). // The fix: the parent's own ID is exactly what AutoNumber reserved for it — untouched by // however many detail rows exist. Assert.Equal(501, dto.DrillDownId); Assert.NotNull(captured); Assert.Equal(dto.DrillDownId, captured!.DrillDownId); // Every child's own DrillDownId FK correctly points at the (unmutated) parent ID. Assert.All(dto.DrillDownDetailArray, child => Assert.Equal(dto.DrillDownId, child.DrillDownId)); // Each child gets its own, distinct DrillDownDetailId from the SEPARATE "DRILLDOWNDETAIL" // AutoNumber reservation — not a reuse of the parent's own ID space. var detailIds = dto.DrillDownDetailArray.ConvertAll(d => d.DrillDownDetailId); Assert.Equal(new List { 901, 902, 903 }, detailIds); } [Fact] public async Task SaveDrillDown_NewWithNoDetailRows_NeverCallsAutoNumberForDetails() { var qe = new Mock(MockBehavior.Strict); qe.Setup(x => x.ExecuteScalarAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(500); // the one call for the parent's own ID only var autoNumber = new AutoNumber(qe.Object, Mock.Of>( o => o.Value == new Gb5SystemDTO { DataBaseType = 0 })); var dal = new Mock(); dal.Setup(d => d.SaveDrillDown(It.IsAny(), It.IsAny())).ReturnsAsync(0); var bll = new DrillDownBLL(dal.Object, autoNumber, Mock.Of()); var dto = new DrillDownDTO { DrillDownCode = "NO_DETAILS", DrillDownDescription = "Test" }; await bll.SaveDrillDown(dto, Login()); // Strict mock allowed exactly one ExecuteScalarAsync call (the parent reservation) — // a second call (for details, which don't exist here) would have thrown a MockException. Assert.Equal(501, dto.DrillDownId); } }