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 (M9-T32c). 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(); 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(); } private void SeedSchemas(params SharedSchema[] schemas) => _service.ListAsync(Arg.Any()) .Returns(Task.FromResult>(schemas.ToList())); [Fact] public void Renders_EmptyState_WhenNoSchemas() { var cut = Render(); 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(); 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(); 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()); } [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())); } [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"); }); } [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()); } [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()); }); } [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())); } /// 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) => Task.FromResult(true); public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); } }