using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
///
/// Deferred-work register row 105 (arch-review 2026-08-19 P4-7 → LocalDb 0.3.3): a replicated
/// table must never be the parent of a foreign key with a row-mutating ON DELETE action
/// (CASCADE / SET NULL / SET DEFAULT). LocalDb's LWW applier upserts with
/// INSERT OR REPLACE under foreign_keys=ON, so the implicit delete would fire the
/// child action while change-capture is suppressed — the child rows change on the applying node
/// only and the pair diverges silently. LocalDb 0.3.3 refuses such a parent at
/// RegisterReplicated time, which on a site node is a BOOT failure of the replicating
/// pair. This test moves that discovery from the first replicated boot to the build: it walks
/// the real site schema (every table SiteLocalDbSetup.OnReady creates) and asserts none
/// of the ten has such an inbound FK.
///
///
/// The schema is built with an UNREPLICATED config on purpose: a replicated one would make
/// 0.3.3's own guard throw first and mask which table/FK is at fault. The checker query mirrors
/// LocalDb's (sqlite_master JOIN pragma_foreign_key_list), and a negative control proves
/// it actually finds an offender.
///
public class SiteLocalDbReplicatedTableFkGuardTests
{
[Fact]
public void NoReplicatedTable_IsAParentOfARowMutatingOnDeleteForeignKey()
{
var path = TempDbPath();
try
{
var config = ConfigFor(path);
var services = new ServiceCollection();
services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config));
using (var provider = services.BuildServiceProvider())
using (var scope = provider.CreateScope())
{
// Resolving ILocalDb runs SqliteLocalDb's constructor and then OnReady — i.e.
// creates every table the consolidated site file will ever hold.
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
}
var tables = TableNames(path);
foreach (var table in SiteLocalDbSetup.ReplicatedTables)
{
Assert.Contains(table, tables); // the list and the schema must not drift apart
var offenders = FindRowMutatingFkChildren(path, table);
Assert.True(offenders.Count == 0,
$"Replicated table '{table}' is the parent of a row-mutating ON DELETE foreign key " +
$"({string.Join(", ", offenders)}). LocalDb 0.3.3 refuses to register it, so a " +
"replicating site pair would fail to boot — model cross-table cleanup as explicit " +
"application deletes instead (register row 105).");
}
}
finally
{
SqliteConnection.ClearAllPools();
Cleanup(path);
}
}
[Fact]
public void NegativeControl_CheckerFindsACascadingChild()
{
// The checker must be able to fail, or the test above proves nothing.
var path = TempDbPath();
try
{
using (var conn = Open(path))
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"CREATE TABLE parent_t (id TEXT PRIMARY KEY);" +
"CREATE TABLE child_cascade (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE CASCADE);" +
"CREATE TABLE child_restrict (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE RESTRICT);";
cmd.ExecuteNonQuery();
}
var offenders = FindRowMutatingFkChildren(path, "parent_t");
var only = Assert.Single(offenders);
Assert.Equal("child_cascade.parent_id ON DELETE CASCADE", only);
}
finally
{
SqliteConnection.ClearAllPools();
Cleanup(path);
}
}
/// Same shape as LocalDb 0.3.3's EnsureNoInboundActionOnDelete query.
private static List FindRowMutatingFkChildren(string path, string parentTable)
{
using var conn = Open(path);
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT m.name, fk.\"from\", fk.\"on_delete\" " +
"FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) fk " +
"WHERE m.type = 'table' AND fk.\"table\" = $t COLLATE NOCASE " +
"AND UPPER(fk.\"on_delete\") IN ('CASCADE', 'SET NULL', 'SET DEFAULT')";
cmd.Parameters.AddWithValue("$t", parentTable);
using var reader = cmd.ExecuteReader();
var result = new List();
while (reader.Read())
result.Add($"{reader.GetString(0)}.{reader.GetString(1)} ON DELETE {reader.GetString(2)}");
return result;
}
private static HashSet TableNames(string path)
{
using var conn = Open(path);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
using var reader = cmd.ExecuteReader();
var names = new HashSet(StringComparer.OrdinalIgnoreCase);
while (reader.Read()) names.Add(reader.GetString(0));
return names;
}
private static SqliteConnection Open(string path)
{
var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
conn.Open();
return conn;
}
private static string TempDbPath() =>
Path.Combine(Path.GetTempPath(), $"scadabridge_fkguard_{Guid.NewGuid():N}.db");
private static IConfiguration ConfigFor(string path) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = path })
.Build();
private static void Cleanup(string path)
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(path + suffix); } catch (IOException) { }
}
}
}