0db253b55f
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.
`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.
Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:
Fail over CentralFailoverControl (central pair, site pair)
Approve SecuredWrites — approve (this one WRITES TO A DEVICE)
Reject SecuredWrites — reject
Disable Topology — disable instance
Discard SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)
The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".
Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
227 lines
9.5 KiB
C#
227 lines
9.5 KiB
C#
using System.Security.Claims;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using SchemaLibraryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.SchemaLibrary;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
|
|
|
/// <summary>
|
|
/// bUnit tests for the Schema Library page (M9-T32c). The page lists the reusable named
|
|
/// JSON-Schema library entries, authors create/edit via the shared <c>SchemaBuilder</c>
|
|
/// component, and deletes with a confirm — every mutation dispatched through
|
|
/// <see cref="ISchemaLibraryService"/> (the guard-running ManagementActor path), never a
|
|
/// direct repo write. The service is substituted so the tests capture the dispatched
|
|
/// commands and simulate success / guard-error responses without a real ManagementActor.
|
|
/// </summary>
|
|
public class SchemaLibraryPageTests : BunitContext
|
|
{
|
|
private readonly ISchemaLibraryService _service = Substitute.For<ISchemaLibraryService>();
|
|
|
|
public SchemaLibraryPageTests()
|
|
{
|
|
Services.AddSingleton(_service);
|
|
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
|
AddTestAuth();
|
|
|
|
// Default: empty library. Tests that need rows override this.
|
|
_service.ListAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(new List<SharedSchema>()));
|
|
}
|
|
|
|
private void AddTestAuth()
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
|
};
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
|
Services.AddAuthorizationCore();
|
|
}
|
|
|
|
private void SeedSchemas(params SharedSchema[] schemas) =>
|
|
_service.ListAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<SharedSchema>>(schemas.ToList()));
|
|
|
|
[Fact]
|
|
public void Renders_EmptyState_WhenNoSchemas()
|
|
{
|
|
var cut = Render<SchemaLibraryPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
Assert.Contains("No library schemas defined", cut.Markup));
|
|
}
|
|
|
|
[Fact]
|
|
public void Lists_ExistingSchemas_WithLibReference()
|
|
{
|
|
SeedSchemas(
|
|
new SharedSchema { Id = 1, Name = "Address", Scope = "global", SchemaJson = "{\"type\":\"object\"}" },
|
|
new SharedSchema { Id = 2, Name = "GeoPoint", SchemaJson = "{\"type\":\"object\"}" });
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("Address", cut.Markup);
|
|
Assert.Contains("GeoPoint", cut.Markup);
|
|
// The list surfaces the lib:Name reference designers paste into a $ref.
|
|
Assert.Contains("lib:Address", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void NewSchema_OpensEditor_WithSchemaBuilder()
|
|
{
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
|
|
|
|
// The editor card mounts with a name field and the SchemaBuilder component.
|
|
Assert.Contains("New Schema", cut.Markup);
|
|
Assert.NotEmpty(cut.FindAll("input")); // name / scope inputs
|
|
Assert.NotNull(cut.FindComponent<SchemaBuilder>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_DispatchesCreateCommand_WithNameAndSchema()
|
|
{
|
|
_service.CreateAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
|
|
|
|
// Type a name and save (the SchemaBuilder seeds a default object schema).
|
|
cut.FindAll("input").First().Change("Address");
|
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
_service.Received(1).CreateAsync(
|
|
"Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_GuardError_IsShownInline_AndEditorStaysOpen()
|
|
{
|
|
_service.CreateAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(SchemaLibraryActionResult.Fail("A schema named 'Address' already exists.")));
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
|
|
cut.FindAll("input").First().Change("Address");
|
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("already exists", cut.Markup);
|
|
// Editor stays open (Save button still present) on a guard error.
|
|
Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "Save");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyName_DoesNotDispatch()
|
|
{
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("No library schemas defined"));
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("New Schema")).Click();
|
|
// Leave name blank and save.
|
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
|
|
|
|
cut.WaitForAssertion(() => Assert.Contains("name is required", cut.Markup));
|
|
_service.DidNotReceive().CreateAsync(
|
|
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_ConfirmsThenDispatchesDeleteCommand_AndReloads()
|
|
{
|
|
SeedSchemas(new SharedSchema { Id = 7, Name = "Address", SchemaJson = "{\"type\":\"object\"}" });
|
|
_service.DeleteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Address"));
|
|
|
|
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Delete")).Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
_service.Received(1).DeleteAsync(7, Arg.Any<CancellationToken>());
|
|
// Reload re-invokes the list query (once on init, once after delete).
|
|
_service.Received(2).ListAsync(Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Edit_OpensEditor_AndDispatchesUpdateCommand()
|
|
{
|
|
SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" });
|
|
_service.UpdateAsync(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(SchemaLibraryActionResult.Ok()));
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Address"));
|
|
|
|
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Edit")).Click();
|
|
Assert.Contains("Edit Schema: Address", cut.Markup);
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Save").Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
_service.Received(1).UpdateAsync(
|
|
9, "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Editing_DisablesRowActions_SoTheRowUnderEditCannotBeDeleted()
|
|
{
|
|
// #260: while the edit form is open the list still shows the row, but its
|
|
// Edit/Delete actions must be disabled so the row under edit (or a sibling)
|
|
// can't be deleted out from under the form.
|
|
SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" });
|
|
|
|
var cut = Render<SchemaLibraryPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Address"));
|
|
|
|
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Edit")).Click();
|
|
Assert.Contains("Edit Schema: Address", cut.Markup);
|
|
|
|
// Every row action button is now disabled while the editor is open.
|
|
var rowButtons = cut.FindAll("tbody tr button");
|
|
Assert.NotEmpty(rowButtons);
|
|
Assert.All(rowButtons, b => Assert.True(b.HasAttribute("disabled")));
|
|
}
|
|
|
|
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
|
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
|
{
|
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<string?> PromptAsync(
|
|
string title, string label, string initialValue = "", string? placeholder = null)
|
|
=> Task.FromResult<string?>(null);
|
|
|
|
public Task<TResult?> ShowAsync<TResult>(
|
|
string title,
|
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
|
string? size = null)
|
|
=> Task.FromResult<TResult?>(default);
|
|
}
|
|
}
|