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);
}
}