using GB5Shared.DTO.Framework.Login;
using Moq;
using SwBLL.DbServer;
using SwBLL.DdlScript;
using SwBLL.Provisioning;
using SwBLL.RefDataSource;
using SwBLL.UpgradePackage;
using SwDAL.CustomCode.RefDataSource;
using SwDAL.DTO.DbServer;
using SwDAL.DTO.DdlScript;
using SwDAL.DTO.RefDataSource;
using SwDAL.DTO.UpgradePackage;
using Xunit;
namespace SwTests;
///
/// Covers RefDataSourceBLL (tracker §36) — the registry that turns "which tables are in scope for
/// reference/standard-data delivery" into a config row instead of a new C# caller per table, the
/// orchestration that resolves a registry row + a source DbServer/database into a real connection
/// string (never a caller-supplied raw connection string) and hands it to
/// ReferenceDataScriptGenerator, and the checksum-diff regeneration check (§36.4) that skips
/// creating a new Draft script when nothing changed since the DbModel's current Released chain.
/// AutoNumber is passed null! for tests that never reach the auto-numbered insert path — same
/// "safe because unused" convention already established in UpgradePackageBLLTests/ProvisioningBLLTests.
///
public class RefDataSourceBLLTests
{
private static (RefDataSourceBLL Svc, Mock Dal, Mock DdlScriptBLL,
Mock UpgradePackageBLL, Mock DbServerBLL,
Mock TargetDbExecutor, Mock Generator) BuildService()
{
var dal = new Mock();
var ddlScript = new Mock();
var upgradePackage = new Mock();
var dbServer = new Mock();
var targetDbExecutor = new Mock();
var generator = new Mock();
var svc = new RefDataSourceBLL(
dal.Object, autoNumber: null!, ddlScript.Object, upgradePackage.Object,
dbServer.Object, targetDbExecutor.Object, generator.Object);
return (svc, dal, ddlScript, upgradePackage, dbServer, targetDbExecutor, generator);
}
private static readonly LoginDTO Login = new() { ClientId = 42, UserId = 1 };
private static void SetupSourceResolution(
Mock dal, Mock dbServer, Mock targetDbExecutor,
Mock generator, RefDataSourceDTO source, string generatedSql)
{
dal.Setup(d => d.GetById(source.RefDataSourceId, Login, default)).ReturnsAsync(source);
dbServer.Setup(d => d.GetByIdWithCredentials(1, Login, default))
.ReturnsAsync(new DbServerDTO { DbServerId = 1, DbType = 0 });
targetDbExecutor.Setup(t => t.BuildConnectionString(It.IsAny(), "SourceDb", null, null))
.Returns("conn-str");
generator.Setup(g => g.GenerateSeedScriptAsync("conn-str", 0, source.TableName, source.RowFilterSql, default))
.ReturnsAsync(generatedSql);
}
// ── Save — guard clauses ─────────────────────────────────────────────────────────────────
[Fact]
public async Task Save_ZeroDbModelId_ThrowsWithoutTouchingDal()
{
var (svc, dal, _, _, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.Save(new RefDataSourceDTO { DbModelId = 0, TableName = "MCURRENCY" }, Login, default));
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Save_EmptyTableName_ThrowsWithoutTouchingDal()
{
var (svc, dal, _, _, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.Save(new RefDataSourceDTO { DbModelId = 1, TableName = "" }, Login, default));
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Save_UniversalScopeWithScopeValue_ThrowsWithoutTouchingDal()
{
var (svc, dal, _, _, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.Save(new RefDataSourceDTO { DbModelId = 1, TableName = "MCURRENCY", ScopeType = 0, ScopeValue = "ACME" }, Login, default));
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Save_NonUniversalScopeWithoutScopeValue_ThrowsWithoutTouchingDal()
{
var (svc, dal, _, _, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.Save(new RefDataSourceDTO { DbModelId = 1, TableName = "Z_ACME_CUSTOM", ScopeType = 4, ScopeValue = null }, Login, default));
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
// ── Save — "Z_" custom-object convention ─────────────────────────────────────────────────
[Theory]
[InlineData("Z_ACME_CUSTOMTABLE")]
[InlineData("z_acme_customtable")] // case-insensitive
public async Task Save_ZPrefixedTableWithUniversalScope_ThrowsWithoutTouchingDal(string tableName)
{
var (svc, dal, _, _, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.Save(new RefDataSourceDTO { DbModelId = 1, TableName = tableName, ScopeType = 0 }, Login, default));
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Save_ZPrefixedTableWithClientScope_Succeeds()
{
// Uses the Update path (non-zero RefDataSourceId), not Insert — Insert needs a real
// AutoNumber (a concrete, non-virtual class Moq can't intercept), matching this file's
// own established "AutoNumber passed null! for tests that don't reach the insert path"
// convention. Update proves the same validation-passes-through behavior without it.
var (svc, dal, _, _, _, _, _) = BuildService();
dal.Setup(d => d.Update(It.IsAny(), Login, default)).ReturnsAsync(1);
var result = await svc.Save(
new RefDataSourceDTO { RefDataSourceId = 9, DbModelId = 1, TableName = "Z_ACME_CUSTOMTABLE", ScopeType = 4, ScopeValue = "ACME" },
Login, default);
dal.Verify(d => d.Update(It.IsAny(), Login, default), Times.Once);
Assert.Contains("Updated", result);
}
[Fact]
public async Task Save_NonZPrefixedTableWithClientScope_Succeeds()
{
// A genuinely universal table can still legitimately have a client-specific override
// package — the "Z_" rule only forbids the OPPOSITE (custom table registered Universal).
var (svc, dal, _, _, _, _, _) = BuildService();
dal.Setup(d => d.Update(It.IsAny(), Login, default)).ReturnsAsync(1);
var result = await svc.Save(
new RefDataSourceDTO { RefDataSourceId = 9, DbModelId = 1, TableName = "MCURRENCY", ScopeType = 4, ScopeValue = "ACME" },
Login, default);
dal.Verify(d => d.Update(It.IsAny(), Login, default), Times.Once);
Assert.Contains("Updated", result);
}
[Fact]
public async Task Save_ExistingRow_CallsUpdateNotInsert()
{
var (svc, dal, _, _, _, _, _) = BuildService();
dal.Setup(d => d.Update(It.IsAny(), Login, default)).ReturnsAsync(1);
var result = await svc.Save(new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 1, TableName = "MCURRENCY" }, Login, default);
dal.Verify(d => d.Update(It.IsAny(), Login, default), Times.Once);
dal.Verify(d => d.Insert(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
Assert.Contains("Updated", result);
}
// ── GenerateAndRegisterAsync ──────────────────────────────────────────────────────────────
[Fact]
public async Task GenerateAndRegisterAsync_RegistryRowNotFound_ThrowsWithoutGenerating()
{
var (svc, dal, _, _, _, _, generator) = BuildService();
dal.Setup(d => d.GetById(99, Login, default)).ReturnsAsync((RefDataSourceDTO?)null);
await Assert.ThrowsAsync(() =>
svc.GenerateAndRegisterAsync(99, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default));
generator.Verify(g => g.GenerateSeedScriptAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task GenerateAndRegisterAsync_DbServerNotFound_ThrowsWithoutGenerating()
{
var (svc, dal, _, _, dbServer, _, generator) = BuildService();
dal.Setup(d => d.GetById(5, Login, default)).ReturnsAsync(new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY" });
dbServer.Setup(d => d.GetByIdWithCredentials(1, Login, default)).ReturnsAsync((DbServerDTO?)null);
await Assert.ThrowsAsync(() =>
svc.GenerateAndRegisterAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default));
generator.Verify(g => g.GenerateSeedScriptAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task GenerateAndRegisterAsync_ResolvesConnectionServerSide_NeverAcceptsRawConnectionString()
{
var (svc, dal, ddlScriptBLL, _, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY", RowFilterSql = "SOURCETYPE IN (1,2)" };
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, "INSERT INTO MCURRENCY ...");
DdlScriptDTO? capturedScript = null;
ddlScriptBLL.Setup(d => d.Save(It.IsAny(), Login, default))
.Callback((dto, _, _) => { capturedScript = dto; dto.DdlScriptId = 12345; })
.ReturnsAsync("saved");
var resultId = await svc.GenerateAndRegisterAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 3, Login, default);
Assert.Equal(12345, resultId);
Assert.Equal(7, capturedScript!.DbModelId);
Assert.Equal(3, capturedScript.BranchId);
Assert.Equal("MCURRENCY", capturedScript.ObjectName);
Assert.Equal(-1, capturedScript.ObjectGroupId);
// The connection string never crosses this BLL's own public API — only DbServerId/DatabaseName do.
dbServer.Verify(d => d.GetByIdWithCredentials(1, Login, default), Times.Once);
targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), "SourceDb", null, null), Times.Once);
}
// ── RegenerateIfChangedAsync ──────────────────────────────────────────────────────────────
[Fact]
public async Task RegenerateIfChangedAsync_NoReleasedPackageTouchesThisTable_TreatsAsChanged()
{
var (svc, dal, ddlScriptBLL, upgradePackageBLL, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY" };
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, "INSERT INTO MCURRENCY ...");
upgradePackageBLL.Setup(u => u.GetProvisioningChain(7, null, Login, default))
.ReturnsAsync(Array.Empty());
ddlScriptBLL.Setup(d => d.Save(It.IsAny(), Login, default))
.Callback((dto, _, _) => dto.DdlScriptId = 999)
.ReturnsAsync("saved");
var result = await svc.RegenerateIfChangedAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default);
Assert.True(result.Changed);
Assert.Equal(999, result.NewDdlScriptId);
Assert.Null(result.PriorChecksum);
ddlScriptBLL.Verify(d => d.Save(It.IsAny(), Login, default), Times.Once);
}
[Fact]
public async Task RegenerateIfChangedAsync_IdenticalChecksumInReleasedChain_SkipsWithoutSavingNewScript()
{
var (svc, dal, ddlScriptBLL, upgradePackageBLL, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY" };
const string sql = "INSERT INTO MCURRENCY (X) VALUES (1);\n";
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, sql);
string sameChecksum = DdlScriptBLL.ComputeChecksum(sql);
upgradePackageBLL.Setup(u => u.GetProvisioningChain(7, null, Login, default))
.ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 100 } });
upgradePackageBLL.Setup(u => u.GetDdlLines(100, Login, default))
.ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 100, DdlScriptId = 55, ObjectName = "MCURRENCY" } });
ddlScriptBLL.Setup(d => d.GetById(55, Login, default))
.ReturnsAsync(new DdlScriptDTO { DdlScriptId = 55, ObjectName = "MCURRENCY", Checksum = sameChecksum });
var result = await svc.RegenerateIfChangedAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default);
Assert.False(result.Changed);
Assert.Null(result.NewDdlScriptId);
Assert.Equal(sameChecksum, result.PriorChecksum);
Assert.Equal(sameChecksum, result.NewChecksum);
ddlScriptBLL.Verify(d => d.Save(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task RegenerateIfChangedAsync_DifferentChecksumInReleasedChain_SavesNewDraftScript()
{
var (svc, dal, ddlScriptBLL, upgradePackageBLL, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY" };
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, "INSERT INTO MCURRENCY (X) VALUES (2);\n");
upgradePackageBLL.Setup(u => u.GetProvisioningChain(7, null, Login, default))
.ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 100 } });
upgradePackageBLL.Setup(u => u.GetDdlLines(100, Login, default))
.ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 100, DdlScriptId = 55, ObjectName = "MCURRENCY" } });
ddlScriptBLL.Setup(d => d.GetById(55, Login, default))
.ReturnsAsync(new DdlScriptDTO { DdlScriptId = 55, ObjectName = "MCURRENCY", Checksum = "stale-checksum-from-an-older-release" });
ddlScriptBLL.Setup(d => d.Save(It.IsAny(), Login, default))
.Callback((dto, _, _) => dto.DdlScriptId = 777)
.ReturnsAsync("saved");
var result = await svc.RegenerateIfChangedAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default);
Assert.True(result.Changed);
Assert.Equal(777, result.NewDdlScriptId);
Assert.Equal("stale-checksum-from-an-older-release", result.PriorChecksum);
ddlScriptBLL.Verify(d => d.Save(It.IsAny(), Login, default), Times.Once);
}
[Fact]
public async Task RegenerateIfChangedAsync_ReleasedPackageHasNoScriptForThisTable_SkipsToNextPackage()
{
var (svc, dal, ddlScriptBLL, upgradePackageBLL, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO { RefDataSourceId = 5, DbModelId = 7, TableName = "MCURRENCY" };
const string sql = "INSERT INTO MCURRENCY (X) VALUES (1);\n";
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, sql);
string sameChecksum = DdlScriptBLL.ComputeChecksum(sql);
// Two packages in the chain (e.g. a Universal one plus a scoped overlay) — only the
// second one happens to carry a script for MCURRENCY.
upgradePackageBLL.Setup(u => u.GetProvisioningChain(7, null, Login, default))
.ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 100 }, new PackageChainItemDTO { PackageId = 200 } });
upgradePackageBLL.Setup(u => u.GetDdlLines(100, Login, default))
.ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 100, DdlScriptId = 1, ObjectName = "MUOM" } });
upgradePackageBLL.Setup(u => u.GetDdlLines(200, Login, default))
.ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 200, DdlScriptId = 55, ObjectName = "MCURRENCY" } });
ddlScriptBLL.Setup(d => d.GetById(55, Login, default))
.ReturnsAsync(new DdlScriptDTO { DdlScriptId = 55, ObjectName = "MCURRENCY", Checksum = sameChecksum });
var result = await svc.RegenerateIfChangedAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default);
Assert.False(result.Changed);
ddlScriptBLL.Verify(d => d.GetById(1, Login, default), Times.Never); // MUOM script never fetched — ObjectName didn't match
}
[Fact]
public async Task RegenerateIfChangedAsync_ClientScopedRegistryRow_WalksClientChainNotUniversal()
{
// A "Z_" custom-table registry row (Client-scoped) must compare against that client's
// own overlay chain — walking the Universal chain instead would never find a match
// (no Universal package ever carries a client-custom script) and would regenerate a new
// Draft on every single call, defeating the whole point of the checksum-diff check.
var (svc, dal, ddlScriptBLL, upgradePackageBLL, dbServer, targetDbExecutor, generator) = BuildService();
var source = new RefDataSourceDTO
{
RefDataSourceId = 5, DbModelId = 7, TableName = "Z_ACME_CUSTOMTABLE", ScopeType = 4, ScopeValue = "ACME",
};
const string sql = "INSERT INTO Z_ACME_CUSTOMTABLE (X) VALUES (1);\n";
SetupSourceResolution(dal, dbServer, targetDbExecutor, generator, source, sql);
string sameChecksum = DdlScriptBLL.ComputeChecksum(sql);
upgradePackageBLL.Setup(u => u.GetProvisioningChain(7,
It.Is(s => s != null && s.ClientCode == "ACME"), Login, default))
.ReturnsAsync(new[] { new PackageChainItemDTO { PackageId = 300 } });
upgradePackageBLL.Setup(u => u.GetDdlLines(300, Login, default))
.ReturnsAsync(new[] { new UpgradePackageDdlDTO { PackageId = 300, DdlScriptId = 60, ObjectName = "Z_ACME_CUSTOMTABLE" } });
ddlScriptBLL.Setup(d => d.GetById(60, Login, default))
.ReturnsAsync(new DdlScriptDTO { DdlScriptId = 60, ObjectName = "Z_ACME_CUSTOMTABLE", Checksum = sameChecksum });
var result = await svc.RegenerateIfChangedAsync(5, sourceDbServerId: 1, "SourceDb", branchId: 1, Login, default);
Assert.False(result.Changed);
upgradePackageBLL.Verify(u => u.GetProvisioningChain(7, null, Login, default), Times.Never);
}
}