using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Host.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// LocalDb Phase 1 (Task 2) — pins the deployment-cache schema and the load-bearing /// DDL → RegisterReplicated → writes ordering inside . /// /// /// /// The ordering assertion is the point of this file. RegisterReplicated installs the /// capture triggers; rows written before it are never captured into the oplog and /// therefore never reach the peer — silently, and forever. A reordered OnReady passes /// every schema-shape test while shipping a replication path that quietly moves nothing. /// /// /// Built against a real temp-file rather than a hand-written schema: /// the library has no in-memory mode, and asserting against a schema the test itself wrote /// would only prove the test agrees with itself. /// /// public sealed class LocalDbSetupTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-localdb-setup-{Guid.NewGuid():N}.db"); private ServiceProvider? _provider; private ILocalDb BuildDb() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) .Build(); _provider = new ServiceCollection() .AddZbLocalDb(configuration, LocalDbSetup.OnReady) .BuildServiceProvider(); return _provider.GetRequiredService(); } [Fact] public void OnReady_RegistersExactlyTheTwoDeploymentTables() { // Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call // (that table then never replicates). "No more" catches an accidental registration — // every replicated table costs three triggers and oplog volume on every write. var db = BuildDb(); db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) .ShouldBe(["deployment_artifacts", "deployment_pointer"]); } [Fact] public void DeploymentArtifacts_PkIsDeploymentIdPlusChunkIndex() { // The composite PK is what makes an artifact chunkable. A single-column PK here would // make every chunk of a deployment collide onto one row under LWW. var db = BuildDb(); db.ReplicatedTables["deployment_artifacts"].PkColumns .ShouldBe(["deployment_id", "chunk_index"]); } [Fact] public void DeploymentPointer_PkIsClusterId() { // One current-deployment pointer per cluster: the pair's two nodes converge on the same // row rather than each keeping its own. var db = BuildDb(); db.ReplicatedTables["deployment_pointer"].PkColumns.ShouldBe(["cluster_id"]); } [Fact] public async Task RowsWrittenAfterOnReady_EnterTheOplog() { // THE ordering assertion. If DDL and RegisterReplicated were swapped — or a write were // added to OnReady ahead of registration — the triggers would not exist at write time and // this count would stay 0 while every other test in this file still passed. var db = BuildDb(); await db.ExecuteAsync( """ INSERT INTO deployment_pointer (cluster_id, deployment_id, revision_hash, artifact_sha256, applied_at_utc) VALUES (@ClusterId, @DeploymentId, @RevisionHash, @Sha, @AppliedAtUtc) """, new { ClusterId = "SITE-A", DeploymentId = "0123456789abcdef0123456789abcdef", RevisionHash = new string('a', 64), Sha = new string('b', 64), AppliedAtUtc = "2026-07-20T00:00:00.0000000Z", }, TestContext.Current.CancellationToken); var oplogRows = await db.QueryAsync( "SELECT COUNT(*) FROM __localdb_oplog", r => r.GetInt32(0), parameters: null, TestContext.Current.CancellationToken); oplogRows[0].ShouldBeGreaterThanOrEqualTo(1); } public void Dispose() { _provider?.Dispose(); // No in-memory mode, so these are real files. Pooled connections keep a handle open past // dispose; clearing the pools first is what makes the delete actually succeed. SqliteConnection.ClearAllPools(); foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" }) { if (File.Exists(path)) File.Delete(path); } } }