35576485b1
v2-ci / unit-tests (push) Has been cancelled
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
123 lines
5.0 KiB
C#
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();
|
|
}
|
|
}
|