using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Arch-review 2026-08-19 P4-6 — PRAGMA auto_vacuum = INCREMENTAL only takes effect on /// a table-less database, and SqliteLocalDb's constructor creates LocalDb's own /// bookkeeping tables before anything else ever gets a connection to the consolidated site /// file. is the fix: it pre-creates /// a genuinely fresh file with the pragma set BEFORE AddZbLocalDb's lazy singleton ever /// constructs SqliteLocalDb. /// /// /// These tests exercise directly /// (it is internal, visible here via InternalsVisibleTo) rather than through the /// full composition root — the mechanism under test is a /// pure ordering/pragma concern that does not depend on the rest of the site graph, and /// already proves the composition root wires /// SiteLocalDbSetup in at all. /// public class SiteLocalDbAutoVacuumTests { [Fact] public void FreshFile_ReportsIncrementalAutoVacuum() { // The plan's own acceptance test: a fresh file reports PRAGMA auto_vacuum = 2 // (INCREMENTAL) after the pre-create step runs. var path = TempDbPath(); try { var config = ConfigFor(path); SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config); Assert.Equal(2L, ReadAutoVacuum(path)); } finally { Cleanup(path); } } [Fact] public void FreshFile_AutoVacuumSurvives_LocalDbCreatingItsOwnBookkeepingTablesAfterward() { // The real end-to-end claim: the pre-create step has to run BEFORE AddZbLocalDb ever // constructs SqliteLocalDb (which creates LocalDb's own tables first, unconditionally, // in its constructor) for the ordering fix to mean anything. This proves the flag // baked in by EnsureAutoVacuumOnFreshFile survives that later table creation — the // exact sequence SiteServiceRegistration.Configure now runs in production. var path = TempDbPath(); try { var config = ConfigFor(path); SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config); var services = new ServiceCollection(); services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config)); using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); // Resolving ILocalDb is what triggers SqliteLocalDb's constructor (LocalDbSchema // .EnsureCreated) and then OnReady (SiteEventLogSchema.Apply, etc.) — i.e. every // real table this file will ever hold. This config carries no // LocalDb:Replication section, so OnReady takes the deregistration branch and // db.ReplicatedTables stays empty — check the raw file instead to confirm the // table genuinely exists. var db = scope.ServiceProvider.GetRequiredService(); Assert.NotNull(db); Assert.Contains("site_events", TableNames(path)); Assert.Equal(2L, ReadAutoVacuum(path)); } finally { SqliteConnection.ClearAllPools(); Cleanup(path); } } [Fact] public void ExistingFile_IsLeftAlone_AutoVacuumStaysNone() { // Honesty requirement (plan decision 2): the fix only helps a file that does not exist // yet. A file that already has tables — every node on the docker rig today, and any // site database created before this fix shipped — is untouched, and stays // auto_vacuum = NONE forever short of an offline VACUUM this task deliberately does // not attempt. var path = TempDbPath(); try { // Pre-populate the file exactly like a real (pre-fix) LocalDb file: at least one // table exists before EnsureAutoVacuumOnFreshFile ever runs. using (var seed = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString())) { seed.Open(); using var cmd = seed.CreateCommand(); cmd.CommandText = "CREATE TABLE legacy_marker (id INTEGER PRIMARY KEY)"; cmd.ExecuteNonQuery(); } Assert.Equal(0L, ReadAutoVacuum(path)); // NONE, the SQLite default. var config = ConfigFor(path); SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config); // Still NONE — the method must not attempt VACUUM against a populated file. Assert.Equal(0L, ReadAutoVacuum(path)); } finally { SqliteConnection.ClearAllPools(); Cleanup(path); } } [Fact] public void MissingLocalDbPath_DoesNotThrow() { // AddZbLocalDb's own options validation is the authoritative place a missing path is // reported; this pre-create step must defer to it rather than throwing its own, // differently-worded error first. var config = new ConfigurationBuilder().AddInMemoryCollection( new Dictionary()).Build(); var ex = Record.Exception(() => SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config)); Assert.Null(ex); } private static string TempDbPath() => Path.Combine(Path.GetTempPath(), $"scadabridge_autovacuum_{Guid.NewGuid():N}.db"); private static IConfiguration ConfigFor(string path) => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = path }) .Build(); private static long ReadAutoVacuum(string path) { using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString()); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA auto_vacuum"; return Convert.ToInt64(cmd.ExecuteScalar()); } private static HashSet TableNames(string path) { using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString()); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'"; using var reader = cmd.ExecuteReader(); var names = new HashSet(StringComparer.Ordinal); while (reader.Read()) names.Add(reader.GetString(0)); return names; } private static void Cleanup(string path) { foreach (var suffix in new[] { "", "-wal", "-shm" }) { try { File.Delete(path + suffix); } catch { /* best effort */ } } } }