using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.ListQuery; using Moq; using SwBLL.UpgradePackage; using SwDAL.CustomCode.DdlScript; using SwDAL.CustomCode.UpgradePackage; using SwDAL.DTO.DdlScript; using SwDAL.DTO.UpgradePackage; using SwDAL.Enums; using Xunit; namespace SwTests; /// /// Covers the previously-missing UpgradePackage script-attachment wiring (§9.3 of /// docs/Platform-Architecture-Integration-Reference.md) — AttachDdlScript/DetachDdlScript, /// thin BLL wrappers over the already-implemented ReplaceDdlLines/GetDdlLines DAL methods — /// plus the "Z_"-prefixed-object-vs-Universal-scope guard added to AttachDdlScript. /// /// These tests exercise only the guard clauses (invalid input, package-not-found, released-package, /// already/not-attached) — the same "guard clauses only" convention already established in /// ClientDatabaseProvisionerTests, since UpgradePackageBLL's constructor pulls in concrete, /// non-interface dependencies (AutoNumber, KeyInvalidate, BaseEntityAppService) that are only /// actually exercised by Save()/Delete(), not by these two new methods. Passing null for them /// here is safe because every case below throws before any of those fields would be touched. /// public class UpgradePackageBLLTests { private static (UpgradePackageBLL Svc, Mock Dal, Mock DdlScriptDal) BuildService() { var dal = new Mock(); var ddlScriptDal = new Mock(); var listHandler = new Mock>(); var svc = new UpgradePackageBLL( dal.Object, ddlScriptDal.Object, autoNumber: null!, queryExecutor: null!, keyInvalidate: null!, listHandler.Object, baseEntityAppService: null!); return (svc, dal, ddlScriptDal); } private static readonly LoginDTO Login = new() { ClientId = 42, UserId = 1 }; [Fact] public async Task AttachDdlScript_ZeroDdlScriptId_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 1, DdlScriptId = 0 }, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AttachDdlScript_PackageNotFound_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync((UpgradePackageDTO?)null); await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); } [Fact] public async Task AttachDdlScript_ReleasedPackage_ThrowsWithoutReplacingLines() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released }); await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); dal.Verify(d => d.ReplaceDdlLines(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AttachDdlScript_AlreadyAttached_ThrowsWithoutReplacingLines() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Draft }); dal.Setup(d => d.GetDdlLines(5, Login, default)) .ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 5, DdlScriptId = 10, Sequence = 1 } }); await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); dal.Verify(d => d.ReplaceDdlLines(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── "Z_"-prefixed object vs Universal-scope package guard (mirrors RefDataSourceBLL.Save's // identical check, applied here instead since PackageScopeType is whole-package, not // per-script — SwDAL.Enums.PackageScopeType's own doc comment) ───────────────────────── [Fact] public async Task AttachDdlScript_ZPrefixedScript_UniversalPackage_ThrowsWithoutReplacingLines() { var (svc, dal, ddlScriptDal) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Draft, ScopeType = (byte)PackageScopeType.Universal }); ddlScriptDal.Setup(d => d.GetById(10, Login, default)) .ReturnsAsync(new DdlScriptDTO { DdlScriptId = 10, ObjectName = "Z_MCLIENT_ACME_EXT" }); var ex = await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); Assert.Contains("Z_", ex.Message); dal.Verify(d => d.ReplaceDdlLines(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AttachDdlScript_NonZPrefixedScript_UniversalPackage_PassesGuard_ReachesLineCheck() { // Confirms the guard is scoped correctly (isUniversal && Z_-prefixed only) — a plain // object name on a Universal package must NOT be rejected by this new check. Verified by // reaching GetDdlLines (the next real step after the guard), not by exercising the full // transactional success path (BeginTransactionAsync/KeyInvalidate — concrete dependencies // this file's own established convention avoids mocking, see class doc comment). var (svc, dal, ddlScriptDal) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Draft, ScopeType = (byte)PackageScopeType.Universal }); dal.Setup(d => d.GetDdlLines(5, Login, default)) .ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 5, DdlScriptId = 10, Sequence = 1 } }); // already attached -> throws AFTER the guard ddlScriptDal.Setup(d => d.GetById(10, Login, default)) .ReturnsAsync(new DdlScriptDTO { DdlScriptId = 10, ObjectName = "MCLIENT" }); var ex = await Assert.ThrowsAsync(() => svc.AttachDdlScript(new AttachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); Assert.Contains("already attached", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task DetachDdlScript_ZeroDdlScriptId_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); await Assert.ThrowsAsync(() => svc.DetachDdlScript(new DetachDdlScriptRequestDTO { PackageId = 1, DdlScriptId = 0 }, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DetachDdlScript_ReleasedPackage_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released }); await Assert.ThrowsAsync(() => svc.DetachDdlScript(new DetachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); } [Fact] public async Task DetachDdlScript_NotAttached_ThrowsWithoutReplacingLines() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)) .ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Draft }); dal.Setup(d => d.GetDdlLines(5, Login, default)) .ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 5, DdlScriptId = 99, Sequence = 1 } }); await Assert.ThrowsAsync(() => svc.DetachDdlScript(new DetachDdlScriptRequestDTO { PackageId = 5, DdlScriptId = 10 }, Login, default)); dal.Verify(d => d.ReplaceDdlLines(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── GetProvisioningChain ───────────────────────────────────────────────── [Fact] public async Task GetProvisioningChain_ZeroDbModelId_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); await Assert.ThrowsAsync(() => svc.GetProvisioningChain(0, null, Login, default)); dal.Verify(d => d.GetProvisioningChain(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetProvisioningChain_ValidDbModelId_ReturnsDalResultInOrder() { var (svc, dal, _) = BuildService(); var chain = new[] { new PackageChainItemDTO { PackageId = 1, PackageCode = "FULL_BASE_SCHEMA_V1", SequenceInChain = 1, ScopeType = 0 }, new PackageChainItemDTO { PackageId = 2, PackageCode = "ENTITLEMENT_BASELINE_V1_PKG", SequenceInChain = 2, ScopeType = 0 }, }; var scope = new PackageScopeFilterDTO { PartnerProductCode = "ACME" }; dal.Setup(d => d.GetProvisioningChain(7, scope, Login, default)).ReturnsAsync(chain); var result = (await svc.GetProvisioningChain(7, scope, Login, default)).ToList(); Assert.Equal(2, result.Count); Assert.Equal("FULL_BASE_SCHEMA_V1", result[0].PackageCode); Assert.Equal("ENTITLEMENT_BASELINE_V1_PKG", result[1].PackageCode); } // ── GetLineage ──────────────────────────────────────────────────────────── [Fact] public async Task GetLineage_ZeroDbModelId_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); await Assert.ThrowsAsync(() => svc.GetLineage(0, Login, default)); dal.Verify(d => d.GetLineage(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetLineage_ValidDbModelId_ReturnsDalResult() { var (svc, dal, _) = BuildService(); var lineage = new[] { new LineageNodeDTO { PackageId = 1, PackageCode = "A", ReleaseVersion = "4.0.0", SupersedesPackageId = null }, new LineageNodeDTO { PackageId = 2, PackageCode = "B", ReleaseVersion = null, SupersedesPackageId = 1 }, }; dal.Setup(d => d.GetLineage(7, Login, default)).ReturnsAsync(lineage); var result = (await svc.GetLineage(7, Login, default)).ToList(); Assert.Equal(2, result.Count); Assert.Equal("4.0.0", result[0].ReleaseVersion); Assert.Null(result[1].ReleaseVersion); } // ── Save — ReleaseVersion format validation ───────────────────────────── [Fact] public async Task Save_ReleaseVersionWithInvalidCharacters_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = (byte)PackageScopeType.Universal, ReleaseVersion = "4.1.x" }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── Save — scope-consistency validation ───────────────────────────────── [Fact] public async Task Save_UniversalScopeWithScopeValue_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = (byte)PackageScopeType.Universal, ScopeValue = "ACME" }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Save_NonUniversalScopeWithoutScopeValue_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = (byte)PackageScopeType.PartnerProduct, ScopeValue = null }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── Save — chain-field immutability once Released ─────────────────────── [Fact] public async Task Save_UpdateReleasedPackage_ChangingScopeType_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released, SequenceInChain = 1, SupersedesPackageId = null, ScopeType = 0, ScopeValue = null, }); var dto = new UpgradePackageDTO { PackageId = 5, ScopeType = (byte)PackageScopeType.PartnerProduct, ScopeValue = "ACME", }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } [Fact] public async Task Save_UpdateReleasedPackage_ChangingSupersedesPackageId_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released, SequenceInChain = 1, SupersedesPackageId = null, ScopeType = 0, ScopeValue = null, }); var dto = new UpgradePackageDTO { PackageId = 5, SequenceInChain = 1, SupersedesPackageId = 4, ScopeType = 0 }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } // ── Save — RolloutType/RolloutPercent validation (§36.5) ───────────────── [Theory] [InlineData((byte)PackageRolloutType.Beta)] [InlineData((byte)PackageRolloutType.SelClients)] public async Task Save_BetaOrSelClientsRolloutType_ThrowsNotSupportedWithoutTouchingDal(byte rolloutType) { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = 0, RolloutType = rolloutType }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Save_RolloutPercentAbove100_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = 0, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 101 }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Save_GlobalRolloutWithNon100Percent_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = 0, RolloutType = (byte)PackageRolloutType.Global, RolloutPercent = 50 }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Save_UpdateReleasedPackage_ChangingRolloutPercent_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released, SequenceInChain = 1, ScopeType = 0, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 20, }); var dto = new UpgradePackageDTO { PackageId = 5, SequenceInChain = 1, ScopeType = 0, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 40, }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } // ── Save — Entitlement-aware gating (§40) ──────────────────────────────── [Fact] public async Task Save_RequiredFeatureCodeOnSchemaPackage_ThrowsWithoutTouchingDal() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = 0, PackageContentType = (byte)PackageContentType.Schema, RequiredFeatureCode = "MOD.PRODUCTION" }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); dal.Verify(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Save_MetadataPackageWithRequiredFeatureCode_DoesNotThrowGatingGuard() { var (svc, dal, _) = BuildService(); var dto = new UpgradePackageDTO { PackageId = 0, ScopeType = 0, PackageContentType = (byte)PackageContentType.MetadataOrStandardData, RequiredFeatureCode = "MOD.PRODUCTION" }; // Guard passes and execution proceeds into the isNew/transaction path — null // AutoNumber/QueryExecutor means it throws further downstream (NullReferenceException, // not the ArgumentException the gating guard itself would throw), proving the gating // guard specifically did not reject this combination. await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } [Fact] public async Task Save_UpdateReleasedPackage_ChangingPackageContentType_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released, SequenceInChain = 1, ScopeType = 0, PackageContentType = (byte)PackageContentType.Schema, RequiredFeatureCode = null, }); var dto = new UpgradePackageDTO { PackageId = 5, SequenceInChain = 1, ScopeType = 0, PackageContentType = (byte)PackageContentType.MetadataOrStandardData, RequiredFeatureCode = "MOD.PRODUCTION", }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } [Fact] public async Task Save_UpdateReleasedPackage_ChangingRequiredFeatureCodeOnly_Throws() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new UpgradePackageDTO { PackageId = 5, PkgStatus = (byte)PkgStatus.Released, SequenceInChain = 1, ScopeType = 0, PackageContentType = (byte)PackageContentType.MetadataOrStandardData, RequiredFeatureCode = "MOD.PRODUCTION", }); var dto = new UpgradePackageDTO { PackageId = 5, SequenceInChain = 1, ScopeType = 0, PackageContentType = (byte)PackageContentType.MetadataOrStandardData, RequiredFeatureCode = "MOD.INVENTORY", }; await Assert.ThrowsAsync(() => svc.Save(dto, Login, default)); } // ── GetProvisioningChain — rollout filtering (§36.5) ───────────────────── [Fact] public async Task GetProvisioningChain_GlobalRolloutPackage_AlwaysIncludedRegardlessOfClientCode() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetProvisioningChain(7, null, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Global, RolloutPercent = 100 }, }); var result = (await svc.GetProvisioningChain(7, null, Login, default)).ToList(); Assert.Single(result); } [Fact] public async Task GetProvisioningChain_PercentageRollout_NoClientCode_Excluded() { var (svc, dal, _) = BuildService(); dal.Setup(d => d.GetProvisioningChain(7, null, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 100 }, }); var result = (await svc.GetProvisioningChain(7, scope: null, Login, default)).ToList(); Assert.Empty(result); } [Fact] public async Task GetProvisioningChain_PercentageRollout_ZeroPercent_AlwaysExcludedEvenWithClientCode() { var (svc, dal, _) = BuildService(); var scope = new PackageScopeFilterDTO { ClientCode = "ANY-CLIENT-CODE" }; dal.Setup(d => d.GetProvisioningChain(7, scope, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 0 }, }); var result = (await svc.GetProvisioningChain(7, scope, Login, default)).ToList(); Assert.Empty(result); } [Fact] public async Task GetProvisioningChain_PercentageRollout_HundredPercent_AlwaysIncludedWithAnyClientCode() { var (svc, dal, _) = BuildService(); var scope = new PackageScopeFilterDTO { ClientCode = "SOME-CLIENT" }; dal.Setup(d => d.GetProvisioningChain(7, scope, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 100 }, }); var result = (await svc.GetProvisioningChain(7, scope, Login, default)).ToList(); Assert.Single(result); } [Fact] public async Task GetProvisioningChain_PercentageRollout_SameClientCode_DeterministicAcrossRepeatedCalls() { var (svc, dal, _) = BuildService(); var scope = new PackageScopeFilterDTO { ClientCode = "REPEATABLE-CLIENT" }; dal.Setup(d => d.GetProvisioningChain(7, scope, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Percentage, RolloutPercent = 50 }, }); var first = (await svc.GetProvisioningChain(7, scope, Login, default)).Count(); var second = (await svc.GetProvisioningChain(7, scope, Login, default)).Count(); Assert.Equal(first, second); // same client, same bucket, every time — not a per-call coin flip } [Fact] public async Task GetProvisioningChain_UnknownRolloutTypeReachingDal_ExcludedAsFailSafe() { var (svc, dal, _) = BuildService(); // Simulates a row that predates Save()'s own Beta/SelClients rejection reaching the DAL // layer somehow — must not silently behave as Global. dal.Setup(d => d.GetProvisioningChain(7, null, Login, default)).ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 1, RolloutType = (byte)PackageRolloutType.Beta, RolloutPercent = 100 }, }); var result = (await svc.GetProvisioningChain(7, null, Login, default)).ToList(); Assert.Empty(result); } }