129 lines
4.8 KiB
C#
129 lines
4.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 2) — pins the deployment-cache schema and the load-bearing
|
|
/// <c>DDL → RegisterReplicated → writes</c> ordering inside <see cref="LocalDbSetup.OnReady"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The ordering assertion is the point of this file. <c>RegisterReplicated</c> installs the
|
|
/// capture triggers; rows written <b>before</b> it are never captured into the oplog and
|
|
/// therefore never reach the peer — silently, and forever. A reordered <c>OnReady</c> passes
|
|
/// every schema-shape test while shipping a replication path that quietly moves nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// Built against a real temp-file <see cref="ILocalDb"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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<string, string?> { ["LocalDb:Path"] = _dbPath })
|
|
.Build();
|
|
|
|
_provider = new ServiceCollection()
|
|
.AddZbLocalDb(configuration, LocalDbSetup.OnReady)
|
|
.BuildServiceProvider();
|
|
|
|
return _provider.GetRequiredService<ILocalDb>();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|