using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; // NOT "...Tests.Deployment", even though the folder is Deployment/. A child namespace named // Deployment under ...Runtime.Tests shadows the Deployment EF entity type for every other file in // this assembly — the same CS0118 trap that forced the production namespace's rename. namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache; /// /// LocalDb Phase 1 (Task 6) — the chunked deployment-artifact cache. /// /// /// /// Driven against a real temp-file rather than a fake. The behaviours /// under test — chunk reassembly order, SHA-256 integrity, retention pruning, upsert /// semantics — live entirely in SQL, so a mocked seam would assert nothing about them. /// /// /// This project cannot reference the Host, so the schema setup here mirrors /// LocalDbSetup.OnReady rather than calling it. The load-bearing /// DDL → RegisterReplicated ordering is pinned by the Host's own /// LocalDbSetupTests; what matters here is only that the tables exist. /// /// public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable { private const string ClusterA = "SITE-A"; private const string ClusterB = "SITE-B"; private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-artifact-cache-{Guid.NewGuid():N}.db"); private readonly ServiceProvider _provider; private readonly ILocalDb _db; public LocalDbDeploymentArtifactCacheTests() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) .Build(); _provider = new ServiceCollection() .AddZbLocalDb(configuration, db => { using (var connection = db.CreateConnection()) { DeploymentCacheSchema.Apply(connection); } db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable); db.RegisterReplicated(DeploymentCacheSchema.PointerTable); }) .BuildServiceProvider(); _db = _provider.GetRequiredService(); } private LocalDbDeploymentArtifactCache CreateCache( ILogger? logger = null) => new(_db, logger ?? NullLogger.Instance); private static byte[] RandomArtifact(int length) { var bytes = new byte[length]; Random.Shared.NextBytes(bytes); return bytes; } private async Task CountChunksAsync(string? deploymentId = null) { var sql = deploymentId is null ? "SELECT COUNT(*) FROM deployment_artifacts" : "SELECT COUNT(*) FROM deployment_artifacts WHERE deployment_id = @DeploymentId"; var rows = await _db.QueryAsync(sql, r => r.GetInt32(0), deploymentId is null ? null : new { DeploymentId = deploymentId }); return rows[0]; } private async Task> DistinctDeploymentIdsAsync() => await _db.QueryAsync( "SELECT DISTINCT deployment_id FROM deployment_artifacts ORDER BY deployment_id", r => r.GetString(0)); /// /// Total capture-trigger rows in the replication oplog — the direct measure of how much /// replication traffic (and, on an unpaired node, how much permanent oplog growth) a store /// costs. /// private async Task OplogDepthAsync() => (await _db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", r => r.GetInt64(0)))[0]; /// /// The LWW version stamps of every replicated row, as a stable ordered projection. Comparing /// it across a call proves whether that call touched replicated state at all. /// private async Task> RowVersionsAsync() => await _db.QueryAsync( "SELECT table_name, pk_json, hlc FROM __localdb_row_version ORDER BY table_name, pk_json", r => $"{r.GetString(0)}|{r.GetString(1)}|{r.GetInt64(2)}"); [Fact] public async Task StoreThenGetCurrent_RoundTripsAMultiChunkArtifactByteForByte() { // 300 KiB against a 128 KiB chunk forces three chunks, so this covers the reassembly // ordering that a single-chunk artifact would never exercise. var artifact = RandomArtifact(300 * 1024); var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-1"); cached.RevisionHash.ShouldBe("rev-1"); cached.Artifact.ShouldBe(artifact); (await CountChunksAsync("dep-1")).ShouldBe(3); } [Fact] public async Task Store_KeepsOnlyTheTwoNewestDeploymentsPerCluster() { var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(64)); await cache.StoreAsync(ClusterA, "dep-2", "rev-2", RandomArtifact(64)); await cache.StoreAsync(ClusterA, "dep-3", "rev-3", RandomArtifact(64)); // Bounded retention is what stops a node that redeploys daily from filling its disk with // address spaces nobody will ever roll back to. (await DistinctDeploymentIdsAsync()).ShouldBe(["dep-2", "dep-3"]); var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-3"); } [Fact] public async Task Store_NeverPrunesTheDeploymentThePointerNames() { // Regression: the prune ranks survivors by cached_at_utc and falls through to a // deployment_id tiebreak. Real deployment ids are GUIDs, so that tiebreak is effectively // random — the deployment just written could lose and have its chunks deleted while the // pointer still named it. The result is a cache that misses on every boot until the next // deploy: silent, permanent, and precisely the outage this cache exists to survive. // // Rather than trying to race three stores into one clock tick, this forces the general // case: the two older deployments are back-dated INTO THE FUTURE so the newest store loses // the ranking outright. The invariant must hold regardless of timestamps. var cache = CreateCache(); var newest = RandomArtifact(1024); await cache.StoreAsync(ClusterA, "dep-old-1", "rev-1", RandomArtifact(1024)); await cache.StoreAsync(ClusterA, "dep-old-2", "rev-2", RandomArtifact(1024)); await _db.ExecuteAsync( "UPDATE deployment_artifacts SET cached_at_utc = @Future", new { Future = "2099-01-01T00:00:00.0000000Z" }); await cache.StoreAsync(ClusterA, "dep-new", "rev-3", newest); // The pointer names dep-new, so dep-new's chunks must still be there... (await CountChunksAsync("dep-new")).ShouldBeGreaterThan(0); // ...and it must actually read back, which is the behaviour that matters. var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-new"); cached.Artifact.ShouldBe(newest); } [Fact] public async Task GetCurrent_ReturnsNullWhenAChunkIsCorrupt() { var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024)); // Valid base64 of the wrong bytes: this passes decoding and the chunk-count check, so only // the SHA-256 comparison can catch it. That is the check being pinned. await _db.ExecuteAsync( "UPDATE deployment_artifacts SET chunk_base64 = @Chunk WHERE deployment_id = @DeploymentId AND chunk_index = 1", new { Chunk = Convert.ToBase64String(RandomArtifact(128 * 1024)), DeploymentId = "dep-1" }); (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull(); } [Fact] public async Task GetCurrent_ReturnsNullWhenAChunkIsMissing() { var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024)); // A partially replicated artifact is the realistic version of this: the pointer row arrives // before the last chunk does. Reassembling what is present would yield a plausible-looking // but silently truncated address space. await _db.ExecuteAsync( "DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2", new { DeploymentId = "dep-1" }); (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull(); } [Fact] public async Task GetCurrent_ReturnsNullWhenNoPointerExists() { var cache = CreateCache(); (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull(); } [Fact] public async Task Store_IsIdempotentForTheSameDeploymentId() { var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024)); // The re-store is smaller, so a delete-before-insert that did not happen would leave the // tail chunks of the first artifact behind — orphans that also break the chunk-count check. var second = RandomArtifact(200 * 1024); await cache.StoreAsync(ClusterA, "dep-1", "rev-1b", second); (await CountChunksAsync("dep-1")).ShouldBe(2); (await CountChunksAsync()).ShouldBe(2); var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.RevisionHash.ShouldBe("rev-1b"); cached.Artifact.ShouldBe(second); } [Fact] public async Task Store_WritesNothingWhenTheCachedArtifactIsAlreadyIdentical() { // Phase 1 live gate, check 8: every store rewrote the chunks and the pointer unconditionally, // so re-caching an artifact the node already holds — which is what boot-from-cache and // RestoreApplied both do on every restart — minted fresh oplog rows for a row nobody changed. // On a replicating pair that is pure churn; on a default-OFF node there is no peer to ack // them, so they sit in the oplog until the age cap eventually prunes them. var cache = CreateCache(); var artifact = RandomArtifact(300 * 1024); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); var oplogDepth = await OplogDepthAsync(); var rowVersions = await RowVersionsAsync(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); (await OplogDepthAsync()).ShouldBe(oplogDepth); (await RowVersionsAsync()).ShouldBe(rowVersions); // Skipping must not cost readability: the artifact still reads back intact. var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-1"); cached.Artifact.ShouldBe(artifact); } [Fact] public async Task Store_RewritesWhenTheCachedChunksAreIncompleteDespiteAMatchingPointer() { // The dangerous half of skip-if-unchanged. A pointer can name a deployment whose chunks are // gone or partial — a half-replicated artifact, or a prune that raced the pointer. Trusting // the pointer alone would turn a repairable cache into a permanently unreadable one, because // the re-store that would have fixed it is exactly the call being skipped. var cache = CreateCache(); var artifact = RandomArtifact(300 * 1024); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); await _db.ExecuteAsync( "DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2", new { DeploymentId = "dep-1" }); (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); (await CountChunksAsync("dep-1")).ShouldBe(3); var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.Artifact.ShouldBe(artifact); } [Fact] public async Task Store_RewritesWhenTheArtifactBytesChangedUnderAnUnchangedRevision() { // The identity check must be over the bytes, not just the ids. A rebuilt artifact carrying // the same deployment id and revision hash is a real case (the same revision re-composed), // and silently keeping the stale bytes would serve a boot-from-cache node the wrong address // space with every id-shaped signal saying it was correct. var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(1024)); var rebuilt = RandomArtifact(1024); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", rebuilt); var cached = await cache.GetCurrentAsync(ClusterA); cached.ShouldNotBeNull(); cached.Artifact.ShouldBe(rebuilt); } [Fact] public async Task GetCurrentUnkeyed_ReturnsTheOnlyCachedArtifact() { var artifact = RandomArtifact(300 * 1024); var cache = CreateCache(); await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact); var cached = await cache.GetCurrentUnkeyedAsync(); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-1"); cached.Artifact.ShouldBe(artifact); } [Fact] public async Task GetCurrentUnkeyed_TakesTheNewestPointerAndWarnsNamingBothClusters() { var newest = RandomArtifact(1024); var logger = new CapturingLogger(); var cache = CreateCache(logger); await cache.StoreAsync(ClusterA, "dep-a", "rev-a", RandomArtifact(1024)); await cache.StoreAsync(ClusterB, "dep-b", "rev-b", newest); var cached = await cache.GetCurrentUnkeyedAsync(); cached.ShouldNotBeNull(); cached.DeploymentId.ShouldBe("dep-b"); cached.Artifact.ShouldBe(newest); // A re-homed node booting a neighbouring cluster's configuration must be operator-visible. // Naming both clusters is what turns "wrong tags appeared" into a one-line diagnosis. var warning = logger.Entries.ShouldHaveSingleItem(); warning.Level.ShouldBe(LogLevel.Warning); warning.Message.ShouldContain(ClusterA); warning.Message.ShouldContain(ClusterB); } public void Dispose() { _provider.Dispose(); // Real files, and pooled connections outlive the provider — clearing the pools 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); } } /// Minimal logger that records formatted messages so a test can assert on them. private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } }