Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentsPageDriftRenderTests.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

123 lines
5.0 KiB
C#

using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using DeploymentsPage = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
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>
/// lmxopcua#903: after a draft change, /deployments sometimes said "In sync" on its first load and only showed
/// the drift after a refresh. The probe (a singleton shared by every circuit) served a verdict cached against a
/// <c>ConfigEdit</c> watermark that the AdminUI's own editors never move. The page must report the drift on the
/// load that follows the change, and must show a neutral "Checking" state — never a verdict — until it knows.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeploymentsPageDriftRenderTests : BunitContext
{
private const string Chip = "[data-testid=drift-chip]";
private string Seed()
{
var name = $"deploy-page-{Guid.NewGuid():N}";
using (var db = UnsTreeTestDb.CreateNamed(name))
{
UnsTreeTestDb.Seed(db);
}
string hash;
using (var db = UnsTreeTestDb.CreateNamed(name))
{
hash = ConfigComposer.SnapshotAndFlattenAsync(db).GetAwaiter().GetResult().RevisionHash;
db.Deployments.Add(new Deployment
{
RevisionHash = hash,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
});
db.SaveChanges();
}
Services.AddSingleton(UnsTreeTestDb.Factory(name));
Services.AddSingleton<AuthenticationStateProvider>(new FakeAuth());
Services.AddSingleton<IAdminOperationsClient>(new UnusedAdminOps());
return name;
}
[Fact]
public void A_draft_change_is_reported_on_the_next_page_load()
{
var name = Seed();
Services.AddSingleton(sp => new DeploymentDriftProbe(sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>()));
// Load 1: draft == sealed.
var first = Render<DeploymentsPage>();
first.WaitForAssertion(() => first.Find(Chip).TextContent.ShouldBe("In sync"));
// Edit the draft the way the AdminUI editors do: directly, with no ConfigEdit audit row.
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",
});
db.SaveChanges();
}
// Load 2 (a new circuit, same singleton probe): must say drift, not the earlier verdict.
var second = Render<DeploymentsPage>();
second.WaitForAssertion(() => second.Find(Chip).TextContent.ShouldBe("Configuration drift"));
}
[Fact]
public void First_render_shows_checking_until_the_comparison_answers()
{
Seed();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Services.AddSingleton(sp => new DeploymentDriftProbe(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
async _ => { await gate.Task; return "a-different-hash"; }));
var cut = Render<DeploymentsPage>();
cut.Find(Chip).TextContent.ShouldStartWith("Checking");
cut.Markup.ShouldNotContain("In sync");
gate.SetResult();
cut.WaitForAssertion(() => cut.Find(Chip).TextContent.ShouldBe("Configuration drift"));
}
private sealed class FakeAuth : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity("test"))));
}
private sealed class UnusedAdminOps : IAdminOperationsClient
{
public Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct) =>
throw new NotSupportedException();
public Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) =>
throw new NotSupportedException();
public Task<ShelveAlarmResult> ShelveAlarmAsync(string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc,
string? comment, CancellationToken ct) => throw new NotSupportedException();
public Task<T> AskAsync<T>(object message, CancellationToken ct) => throw new NotSupportedException();
}
}