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);
}
}