Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs
T
Joseph Doherty 64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: backfill XML documentation across 756 files
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00

148 lines
5.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class LiteDbConfigCacheTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
/// <summary>Cleans up the temporary database file.</summary>
public void Dispose()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
private GenerationSnapshot Snapshot(string cluster, long gen) => new()
{
ClusterId = cluster,
GenerationId = gen,
CachedAt = DateTime.UtcNow,
PayloadJson = $"{{\"g\":{gen}}}",
};
/// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
[Fact]
public async Task Roundtrip_preserves_payload()
{
using var cache = new LiteDbConfigCache(_dbPath);
var put = Snapshot("c-1", 42);
await cache.PutAsync(put);
var got = await cache.GetMostRecentAsync("c-1");
got.ShouldNotBeNull();
got!.GenerationId.ShouldBe(42);
got.PayloadJson.ShouldBe(put.PayloadJson);
}
/// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
[Fact]
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
{
using var cache = new LiteDbConfigCache(_dbPath);
foreach (var g in new long[] { 10, 20, 15 })
await cache.PutAsync(Snapshot("c-1", g));
var got = await cache.GetMostRecentAsync("c-1");
got!.GenerationId.ShouldBe(20);
}
/// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
[Fact]
public async Task GetMostRecent_returns_null_for_unknown_cluster()
{
using var cache = new LiteDbConfigCache(_dbPath);
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
}
/// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
[Fact]
public async Task Prune_keeps_latest_N_and_drops_older()
{
using var cache = new LiteDbConfigCache(_dbPath);
for (long g = 1; g <= 15; g++)
await cache.PutAsync(Snapshot("c-1", g));
await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10);
(await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15);
// Drop them one by one and count — should be exactly 10 remaining
var count = 0;
while (await cache.GetMostRecentAsync("c-1") is not null)
{
count++;
await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count));
if (count > 20) break; // safety
}
count.ShouldBe(10);
}
/// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
[Fact]
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
{
using var cache = new LiteDbConfigCache(_dbPath);
var first = Snapshot("c-1", 1);
first.PayloadJson = "{\"v\":1}";
await cache.PutAsync(first);
var second = Snapshot("c-1", 1);
second.PayloadJson = "{\"v\":2}";
await cache.PutAsync(second);
(await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
}
// ------------------------------------------------------------------------------------
// Configuration-005 — concurrent PutAsync for the same (ClusterId, GenerationId) must
// not produce duplicate rows. The original find-then-insert was non-atomic so two racing
// callers could both observe `existing is null` and both Insert.
// ------------------------------------------------------------------------------------
/// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
[Fact]
public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
{
using var cache = new LiteDbConfigCache(_dbPath);
// Pre-seed gen=99 so prune keepLatest:1 has a sentinel that survives independent of
// any potential duplicate (gen=42) row count.
await cache.PutAsync(Snapshot("c-1", 99));
// Many parallel writes for the same key. Without serialization, racing find-then-insert
// would Insert multiple rows for the same (ClusterId, GenerationId=42).
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
{
var s = Snapshot("c-1", 42);
await cache.PutAsync(s);
})).ToArray();
await Task.WhenAll(tasks);
// Count rows for gen=42 directly by inspecting the LiteDB file via a fresh handle.
cache.Dispose();
using var verify = new LiteDB.LiteDatabase(_dbPath);
var col = verify.GetCollection<GenerationSnapshot>("generations");
var gen42Count = col.Find(s => s.ClusterId == "c-1" && s.GenerationId == 42).Count();
gen42Count.ShouldBe(1,
$"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts");
}
/// <summary>Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException.</summary>
[Fact]
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
{
// Write a file large enough to look like a LiteDB page but with garbage contents so page
// deserialization fails on the first read probe.
File.WriteAllBytes(_dbPath, new byte[8192]);
Array.Fill<byte>(File.ReadAllBytes(_dbPath), 0xAB);
using (var fs = File.OpenWrite(_dbPath))
{
fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
}
Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
}
}