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, db => LocalDbSetup.OnReady(db, configuration))
.BuildServiceProvider();
return _provider.GetRequiredService();
}
[Fact]
public void OnReady_RegistersExactlyTheThreeReplicatedTables()
{
// 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(["alarm_sf_events", "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 void AlarmSfEvents_PkIsTheAppMintedId()
{
// A single TEXT PK the application mints. The legacy queue this table replaces keyed on
// `RowId INTEGER PRIMARY KEY AUTOINCREMENT`, which LWW cannot replicate: two nodes would
// independently allocate rowid 7 for different alarms and silently overwrite each other.
var db = BuildDb();
db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
}
[Fact]
public async Task AlarmSfEventRows_EnterTheOplog()
{
// Same ordering assertion as the deployment-pointer test below, for the table Phase 2 adds.
// Worth its own case because the alarm table is registered by a different call site, and a
// registration added ahead of its DDL would fail loudly while one added after the migrator
// would fail silently.
var db = BuildDb();
await db.ExecuteAsync(
"""
INSERT INTO alarm_sf_events
(id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0)
""",
new
{
Id = new string('c', 64),
AlarmId = "equip/alarm-1",
EnqueuedAtUtc = "2026-07-21T00:00:00.0000000Z",
PayloadJson = """{"AlarmId":"equip/alarm-1"}""",
},
TestContext.Current.CancellationToken);
var oplogRows = await db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'",
r => r.GetInt32(0),
parameters: null,
TestContext.Current.CancellationToken);
oplogRows[0].ShouldBe(1);
}
[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);
}
}
}