Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeTests.cs
T
Joseph Doherty 35576485b1
v2-ci / unit-tests (push) Has been cancelled
fix(adminui): deployments drift chip never serves a stale "In sync" (#903)
Root cause: DeploymentDriftProbe (a singleton shared by every circuit) cached its verdict until
the ConfigEdit audit watermark (MAX(EditedAtUtc), COUNT(*)) moved, assuming every configuration
write appends a ConfigEdit row. Only deployments and driver restart/reconnect do; the AdminUI
editors (RawTreeService, UnsTreeService, ScriptEdit, cluster/node/ACL pages) write the config
tables directly. So after a draft change the page kept showing the cached "In sync" until
something unrelated moved the watermark.

- DeploymentDriftProbe: the watermark cache is gone; every verdict comes from a composition that
  started after the caller asked. Compositions stay serialised behind the gate, and callers queued
  behind one share a verdict only when that composition started after they arrived (start
  sequence number) for the same sealed hash, so sharing is exact, never stale.
- Deployments page: the drift check runs in OnAfterRenderAsync (interactive render only, so a
  visit costs one composition, not two with prerender); the first render shows a neutral
  "Checking for changes..." chip, never a verdict; a failed check shows "Drift check failed" and
  logs a warning instead of crashing the page. A deploy re-runs the check.
- Tests: probe tests for an un-audited draft change (real ConfigComposer over InMemory), fresh
  composition per call, and queued callers not reusing an older composition; bUnit page tests that
  a second page load after a direct draft change shows "Configuration drift" and that the first
  render shows "Checking" until the comparison answers. All fail against the previous code.
  DeploymentDriftProbeSqlServerTests is removed: it pinned the SQL translation of the watermark
  query, which no longer exists.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 01:50:37 -05:00

127 lines
5.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
/// <summary>
/// Pins the <see cref="DeploymentDriftProbe"/> freshness contract (lmxopcua#903): every verdict comes from a
/// composition that started after the caller asked, so a draft change made by a path that writes no
/// <c>ConfigEdit</c> audit row (every AdminUI editor) is never hidden behind an earlier "In sync"; concurrent
/// callers still share one composition when that composition is fresh for all of them.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeploymentDriftProbeTests
{
private const string SealedHash = "sealed-hash-aaaa";
[Fact]
public async Task A_draft_change_without_an_audit_row_is_reported_as_drift()
{
// The real composer over a real (InMemory) configuration: seal the current state, then edit the draft the
// way RawTreeService/UnsTreeService/ClusterEdit do — straight into the table, no ConfigEdit row.
var name = $"drift-{Guid.NewGuid():N}";
var factory = UnsTreeTestDb.Factory(name);
await using (var db = UnsTreeTestDb.CreateNamed(name))
{
UnsTreeTestDb.Seed(db);
}
var sealedHash = await CurrentHashAsync(name);
var probe = new DeploymentDriftProbe(factory);
(await probe.GetDriftAsync(sealedHash, TestContext.Current.CancellationToken)).ShouldBe(false);
await using (var db = UnsTreeTestDb.CreateNamed(name))
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "NEW-1", Name = "New", Enterprise = "zb", Site = "s9",
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
db.ConfigEdits.Any().ShouldBeFalse(); // the precondition the old watermark cache missed
}
(await probe.GetDriftAsync(sealedHash, TestContext.Current.CancellationToken)).ShouldBe(true);
}
[Fact]
public async Task Sequential_calls_each_compose()
{
var compose = new GatedCompose(SealedHash, gated: false);
var probe = new DeploymentDriftProbe(UnsTreeTestDb.Factory($"drift-{Guid.NewGuid():N}"), compose.ComposeAsync);
(await probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken)).ShouldBe(false);
compose.Result = "a-different-hash";
(await probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken)).ShouldBe(true);
compose.Count.ShouldBe(2);
}
[Fact]
public async Task Callers_queued_behind_an_older_composition_do_not_reuse_it_but_share_a_fresh_one()
{
var compose = new GatedCompose(SealedHash, gated: true);
var probe = new DeploymentDriftProbe(UnsTreeTestDb.Factory($"drift-{Guid.NewGuid():N}"), compose.ComposeAsync);
// A starts composing and blocks inside the composer.
var a = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
await compose.WaitForStartAsync(1);
// B and C ask while A's composition (which may predate their edits) is in flight.
compose.Result = "a-different-hash";
var b = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
var c = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
compose.Release();
(await a).ShouldBe(false);
(await b).ShouldBe(true); // not A's stale answer
(await c).ShouldBe(true);
compose.Count.ShouldBe(2); // A's, then ONE fresh composition shared by B and C
}
private static async Task<string> CurrentHashAsync(string name)
{
await using var db = UnsTreeTestDb.CreateNamed(name);
return (await ConfigComposer.SnapshotAndFlattenAsync(db)).RevisionHash;
}
/// <summary>Stand-in for the <c>ConfigComposer</c> seam that counts calls and can hold them open.</summary>
private sealed class GatedCompose(string result, bool gated)
{
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _count;
public string Result { get; set; } = result;
public int Count => Volatile.Read(ref _count);
public void Release() => _release.TrySetResult();
public async Task WaitForStartAsync(int n)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (Count < n)
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("composition never started");
await Task.Delay(5);
}
}
public async Task<string> ComposeAsync(OtOpcUaConfigDbContext db)
{
var snapshot = Result; // what the configuration looked like when this composition read it
Interlocked.Increment(ref _count);
if (gated) await _release.Task;
return snapshot;
}
}
}