using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// /// Pins as the single owner of the /// sf_messages DDL. The Host applies this to a LocalDb-managed connection /// before RegisterReplicated installs the capture triggers, so the shape it /// produces is the shape that replicates. /// public class StoreAndForwardSchemaTests { [Fact] public void Apply_IsIdempotent_AndCreatesEveryColumn() { var path = Path.Combine(Path.GetTempPath(), $"sf-schema-{Guid.NewGuid():N}.db"); try { using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); StoreAndForwardSchema.Apply(connection); StoreAndForwardSchema.Apply(connection); // second run must not throw using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT name FROM pragma_table_info('sf_messages')"; var columns = new List(); using var reader = cmd.ExecuteReader(); while (reader.Read()) columns.Add(reader.GetString(0)); // 12 original + 4 additive Assert.Equal(16, columns.Count); Assert.Contains("last_attempt_at_ms", columns); Assert.Contains("parent_execution_id", columns); } finally { SqliteConnection.ClearAllPools(); File.Delete(path); } } /// /// The additive columns exist to upgrade a database created by an older build in /// place. Asserting only against a freshly-created table would pass even if every /// ALTER were deleted, because CREATE TABLE already lists them — so /// this starts from the pre-upgrade shape and proves the ALTER path runs. /// [Fact] public void Apply_UpgradesALegacyTable_WithoutLosingRows() { var path = Path.Combine(Path.GetTempPath(), $"sf-schema-legacy-{Guid.NewGuid():N}.db"); try { using var connection = new SqliteConnection($"Data Source={path}"); connection.Open(); // The 12-column shape as it existed before execution_id / source_script / // parent_execution_id / last_attempt_at_ms were added. using (var legacy = connection.CreateCommand()) { legacy.CommandText = """ CREATE TABLE sf_messages ( id TEXT PRIMARY KEY, category INTEGER NOT NULL, target TEXT NOT NULL, payload_json TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 50, retry_interval_ms INTEGER NOT NULL DEFAULT 30000, created_at TEXT NOT NULL, last_attempt_at TEXT, status INTEGER NOT NULL DEFAULT 0, last_error TEXT, origin_instance TEXT ); INSERT INTO sf_messages (id, category, target, payload_json, created_at, last_attempt_at) VALUES ('legacy-1', 0, 'target-a', '{}', '2026-01-01T00:00:00Z', '2026-01-02T03:04:05Z'); """; legacy.ExecuteNonQuery(); } StoreAndForwardSchema.Apply(connection); using var check = connection.CreateCommand(); check.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = 'last_attempt_at_ms'"; Assert.Equal(1, Convert.ToInt32(check.ExecuteScalar())); // The pre-existing row survives and its epoch-ms sibling is backfilled from // the authoritative text timestamp. using var backfilled = connection.CreateCommand(); backfilled.CommandText = "SELECT last_attempt_at_ms FROM sf_messages WHERE id = 'legacy-1'"; var ms = Convert.ToInt64(backfilled.ExecuteScalar()); var expected = DateTimeOffset.Parse("2026-01-02T03:04:05Z").ToUnixTimeMilliseconds(); // Tolerance of 1 ms, not sloppiness: the backfill derives epoch-ms from // julianday(), whose double-precision day-fraction cannot represent every // millisecond exactly, so it lands 1 ms low here. That rounding is // pre-existing production behaviour carried over verbatim from // StoreAndForwardStorage — the column is a sweep-ordering hint, with the // ISO-8601 text column remaining authoritative for reads. Asserting exact // equality would pin a precision the implementation never had. Assert.InRange(ms, expected - 1, expected + 1); } finally { SqliteConnection.ClearAllPools(); File.Delete(path); } } }