using Bunit; using Microsoft.AspNetCore.Components; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using ZB.MOM.WW.ScadaBridge.Security; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using DashboardPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Dashboard; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// Dashboard facts distinguish independent failures, loading and known zero. public class DashboardStateTests : BunitContext { private readonly ISiteRepository _sites = Substitute.For(); private readonly ITemplateEngineRepository _templates = Substitute.For(); private readonly IInboundApiKeyAdmin _keys = Substitute.For(); /// Registers deterministic read-only dependencies and a signed-in fixture. public DashboardStateTests() { SetRoles("Viewer"); _sites.GetAllSitesAsync().Returns(Task.FromResult>([new("A", "a"), new("B", "b")])); _sites.GetAllDataConnectionsAsync().Returns(Task.FromResult>([new("Link", "OpcUa", 1)])); _templates.GetTemplateStubsAsync().Returns(Task.FromResult>([ new(1,"A",null,null,false,null), new(2,"B",null,null,false,null), new(3,"C",null,null,false,null)])); _keys.ListAsync().Returns(Task.FromResult>([])); Services.AddSingleton(_sites); Services.AddSingleton(_templates); Services.AddSingleton(_keys); } private void SetRoles(params string[] roles) { var claims = new List { new(JwtTokenService.UsernameClaimType, "fixture") }; claims.AddRange(roles.Select(role => new Claim(JwtTokenService.RoleClaimType, role))); Services.AddSingleton(new TestAuthStateProvider( new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")))); Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaBridgeAuthorization(Services); Services.AddSingleton(); } private IRenderedComponent RenderDashboard() => Render(parameters => parameters.Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))).FindComponent(); /// Shortcut visibility evaluates the same real policies as navigation and destination routes. [Theory] [InlineData("Viewer", false, true)] [InlineData("Designer", true, false)] [InlineData("Deployer", false, false)] [InlineData("Administrator", false, true)] [InlineData("Administrator,Designer,Deployer", true, true)] [InlineData("Unmapped", false, false)] public void Shortcuts_RespectDestinationPolicies(string role, bool templates, bool audit) { SetRoles(role.Split(',')); var cut = RenderDashboard(); Assert.Single(cut.FindAll("a[href='/monitoring/health']")); Assert.Equal(templates ? 1 : 0, cut.FindAll("a[href='/design/templates']").Count); Assert.Equal(audit ? 1 : 0, cut.FindAll("a[href='/audit/configuration']").Count); } /// Each read failure leaves the other facts intact and is never a false zero. [Theory] [InlineData("Sites configured")] [InlineData("Data connections configured")] [InlineData("Templates")] [InlineData("API keys")] public void CountFailure_DoesNotEraseOtherFactsOrRenderZero(string label) { if(label=="Sites configured")_sites.GetAllSitesAsync().Returns(Task.FromException>(new Exception("local failure"))); if(label=="Data connections configured")_sites.GetAllDataConnectionsAsync().Returns(Task.FromException>(new Exception("local failure"))); if(label=="Templates")_templates.GetTemplateStubsAsync().Returns(Task.FromException>(new Exception("local failure"))); if(label=="API keys")_keys.ListAsync().Returns(Task.FromException>(new Exception("local failure"))); var cut=RenderDashboard(); var facts=new Dictionary{{"Sites configured","2"},{"Data connections configured","1"},{"Templates","3"},{"API keys","0"}}; foreach(var (name,count) in facts) { var fact=cut.Find("[role=region][aria-label='"+name+"']"); Assert.Contains(name==label?"Unavailable":count,fact.TextContent); if(name==label) { Assert.Contains("Retry "+name,fact.TextContent); Assert.DoesNotContain("0",fact.TextContent); } } } /// Only a failed API count is retried, and a real zero is displayed after success. [Fact] public void RetryFailure_PreservesSuccessfulFactsAndCanRecoverToZero() { _keys.ListAsync().Returns(Task.FromException>(new Exception("local failure"))); var cut=RenderDashboard(); Assert.Contains("Unavailable",cut.Find("[aria-label='API keys']").TextContent); _keys.ListAsync().Returns(Task.FromResult>([])); cut.Find("[aria-label='API keys'] button").Click(); Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent); Assert.DoesNotContain("Unavailable",cut.Markup); _sites.Received(1).GetAllSitesAsync(); _sites.Received(1).GetAllDataConnectionsAsync(); _templates.Received(1).GetTemplateStubsAsync(); } /// Early retries share the initial-read gate and duplicate retries remain single-flight. [Fact] public async Task RetryDuringNextRead_WaitsWithoutOverlapAndCoalescesDuplicateTarget() { var connectionRead = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); _sites.GetAllSitesAsync().Returns(Task.FromException>(new Exception("local failure")), Task.FromResult>([new("Recovered", "recovered")])); _sites.GetAllDataConnectionsAsync().Returns(connectionRead.Task); var cut = RenderDashboard(); Assert.Contains("Unavailable", cut.Find("[aria-label='Sites configured']").TextContent); Assert.Contains("Loading", cut.Find("[aria-label='Data connections configured']").TextContent); // Model two already-dispatched retries without relying on a stale DOM node. var retry = typeof(DashboardPage).GetMethod("LoadSites", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; Task first = null!, second = null!; await cut.InvokeAsync(() => { first = (Task)retry.Invoke(cut.Instance, null)!; second = (Task)retry.Invoke(cut.Instance, null)!; }); Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync))); Assert.False(first.IsCompleted); await cut.InvokeAsync(() => connectionRead.SetResult([new("Link", "OpcUa", 1)])); await Task.WhenAll(first, second); cut.WaitForAssertion(() => Assert.Contains("1", cut.Find("[aria-label='Sites configured']").TextContent)); Assert.Equal(2, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync))); Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllDataConnectionsAsync))); } /// An outstanding read remains loading while already loaded facts are available. [Fact] public async Task DelayedRead_RemainsLoadingUntilKnownZeroArrives() { var pending=new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); _keys.ListAsync().Returns(pending.Task); var cut=RenderDashboard(); Assert.Contains("Loading",cut.Find("[aria-label='API keys']").TextContent); Assert.Contains("2",cut.Find("[aria-label='Sites configured']").TextContent); await cut.InvokeAsync(()=>pending.SetResult([])); cut.WaitForAssertion(()=>Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent)); Assert.DoesNotContain("Loading",cut.Find("[aria-label='API keys']").TextContent); } }