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; /// /// bUnit tests for the Schema Library page. The page lists the reusable named /// JSON-Schema library entries, authors create/edit via the shared SchemaBuilder /// component, and deletes with a confirm — every mutation dispatched through /// (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. /// public class SchemaLibraryPageTests : BunitContext { private readonly ISchemaLibraryService _service = Substitute.For(); /// Initializes the test context with a substituted schema library service and an auto-confirming dialog service. public SchemaLibraryPageTests() { Services.AddSingleton(_service); Services.AddSingleton(new AlwaysConfirmDialogService()); AddTestAuth(); // Default: empty library. Tests that need rows override this. _service.ListAsync(Arg.Any()) .Returns(Task.FromResult>(new List())); } 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(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); Services.AddTestConfigurationMutations(); } private void SeedSchemas(params SharedSchema[] schemas) => _service.ListAsync(Arg.Any()) .Returns(Task.FromResult>(schemas.ToList())); /// Verifies that the page renders the empty state when there are no schemas. [Fact] public void Renders_EmptyState_WhenNoSchemas() { var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("No library schemas defined", cut.Markup)); } /// Verifies that existing schemas are listed together with their library reference. [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(); 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); }); } /// Verifies that choosing New Schema opens the editor with the schema builder. [Fact] public void NewSchema_OpensEditor_WithSchemaBuilder() { var cut = Render(); 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()); } /// Verifies that creating a schema dispatches a create command carrying the name and schema. [Fact] public void Create_DispatchesCreateCommand_WithNameAndSchema() { _service.CreateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SchemaLibraryActionResult.Ok())); var cut = Render(); 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(), Arg.Any(), Arg.Any())); } /// Verifies that a guard error from a create is shown inline and the editor stays open. [Fact] public void Create_GuardError_IsShownInline_AndEditorStaysOpen() { _service.CreateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SchemaLibraryActionResult.Fail("A schema named 'Address' already exists."))); var cut = Render(); 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"); }); } /// Verifies that saving with an empty name dispatches no command. [Fact] public void EmptyName_DoesNotDispatch() { var cut = Render(); 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(), Arg.Any(), Arg.Any(), Arg.Any()); } /// Verifies that deleting a schema confirms first, dispatches a delete command and reloads the list. [Fact] public void Delete_ConfirmsThenDispatchesDeleteCommand_AndReloads() { SeedSchemas(new SharedSchema { Id = 7, Name = "Address", SchemaJson = "{\"type\":\"object\"}" }); _service.DeleteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SchemaLibraryActionResult.Ok())); var cut = Render(); 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()); // Reload re-invokes the list query (once on init, once after delete). _service.Received(2).ListAsync(Arg.Any()); }); } /// Verifies that editing a schema opens the editor and dispatches an update command. [Fact] public void Edit_OpensEditor_AndDispatchesUpdateCommand() { SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" }); _service.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SchemaLibraryActionResult.Ok())); var cut = Render(); 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(), Arg.Any(), Arg.Any())); } /// Verifies that row actions are disabled while the editor is open so the row under edit cannot be deleted. [Fact] public void Editing_DisablesRowActions_SoTheRowUnderEditCannotBeDeleted() { // 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(); 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"))); } /// A dialog service that auto-confirms, so the delete path runs end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { /// public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); // Register row 75 added a DialogSeverity overload. This stub auto-confirms // regardless of severity — it exists to let action paths run end to end, not to // assert anything about styling. /// public Task ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); /// public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); /// Returns the default result without showing a dialog. /// The type of result the dialog would produce. /// The dialog title. /// The dialog body fragment. /// The optional dialog size. /// A task whose result is the default value of . public Task ShowAsync( string title, Microsoft.AspNetCore.Components.RenderFragment> body, string? size = null) => Task.FromResult(default); } }