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