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));
[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 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)));
}
}