refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,72 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Security;
using ApiKeyForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKeyForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page. The chip
/// routes operators into the central Audit Log pre-filtered by Actor = ApiKey.Name
/// AND Channel = ApiInbound (no other channel uses the key name as actor, but
/// the explicit channel scope keeps deep links tight). Create mode suppresses
/// the link — there's no API key to drill into yet.
/// </summary>
public class ApiKeyFormAuditDrillinTests : BunitContext
{
private readonly IInboundApiRepository _repo = Substitute.For<IInboundApiRepository>();
public ApiKeyFormAuditDrillinTests()
{
Services.AddSingleton(_repo);
var claims = new[]
{
new Claim("Username", "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel()
{
var key = ApiKey.FromHash("Orders-Integration", "k-hash");
key.Id = 11;
_repo.GetApiKeyByIdAsync(11, Arg.Any<CancellationToken>()).Returns(key);
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
var cut = Render<ApiKeyForm>(p => p.Add(c => c.Id, 11));
cut.WaitForAssertion(() =>
{
var link = cut.Find("a[data-test=\"audit-link\"]");
Assert.Equal(
"/audit/log?actor=Orders-Integration&channel=ApiInbound",
link.GetAttribute("href"));
Assert.Contains("Recent audit activity", link.TextContent);
});
}
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
var cut = Render<ApiKeyForm>();
cut.WaitForAssertion(() =>
{
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
});
}
}
@@ -0,0 +1,73 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using SiteForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.SiteForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the Site edit page. The chip
/// routes operators into the central Audit Log pre-filtered by SourceSiteId =
/// Site.SiteIdentifier (the same string the audit pipeline stamps onto every
/// site-sourced row). Create mode suppresses the link — there's no site yet.
/// </summary>
public class SiteFormAuditDrillinTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly CommunicationService _comms;
public SiteFormAuditDrillinTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_comms);
var claims = new[]
{
new Claim("Username", "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier()
{
_siteRepo.GetSiteByIdAsync(3, Arg.Any<CancellationToken>())
.Returns(new Site("Plant A", "plant-a") { Id = 3 });
var cut = Render<SiteForm>(p => p.Add(c => c.Id, 3));
cut.WaitForAssertion(() =>
{
var link = cut.Find("a[data-test=\"audit-link\"]");
Assert.Equal("/audit/log?site=plant-a", link.GetAttribute("href"));
Assert.Contains("Recent audit activity", link.TextContent);
});
}
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
var cut = Render<SiteForm>();
cut.WaitForAssertion(() =>
{
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
});
}
}
@@ -0,0 +1,105 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Regression tests for CentralUI-012. The Sites page loaded all sites and then
/// issued <c>GetDataConnectionsBySiteIdAsync</c> once per site (N+1 database
/// round-trips on every load and post-delete refresh). The fix fetches all
/// data connections in a single <c>GetAllDataConnectionsAsync</c> call and
/// groups them client-side.
/// </summary>
public class SitesPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private void RegisterServices()
{
Services.AddSingleton(_siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var artifactSvc = new ArtifactDeploymentService(
_siteRepo,
Substitute.For<IDeploymentManagerRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IExternalSystemRepository>(),
Substitute.For<INotificationRepository>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
Services.AddSingleton(artifactSvc);
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "admin") }, "TestCookie");
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
Services.AddSingleton<AuthenticationStateProvider>(
new StubAuthStateProvider(authState));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
private static DataConnection Conn(int siteId, string name)
=> new(name, "OpcUa", siteId);
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "c1"), Conn(2, "c2"), Conn(3, "c3"),
});
Render<SitesPage>();
// Regression: exactly one bulk query, and zero per-site queries.
_siteRepo.Received(1).GetAllDataConnectionsAsync();
_siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(Arg.Any<int>());
}
[Fact]
public void LoadData_GroupsConnectionsBySite_AndRendersThem()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "alpha-conn"), Conn(2, "beta-conn"),
});
var cut = Render<SitesPage>();
Assert.Contains("alpha-conn", cut.Markup);
Assert.Contains("beta-conn", cut.Markup);
}
}
@@ -0,0 +1,324 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
/// <summary>
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
/// <c>ScriptAnalysisEndpoints</c>) rather than MVC controllers, so this brief's
/// "controller" is implemented as <see cref="AuditExportEndpoints"/>. The tests
/// pin two things: (a) the <c>GET /api/centralui/audit/export</c> route sets
/// the correct content-type + attachment disposition + body, and (b) the
/// query-string is parsed into an <see cref="AuditLogQueryFilter"/> and handed
/// to <see cref="IAuditLogExportService"/>.
/// </para>
/// </summary>
public class AuditExportEndpointsTests
{
private static AuditEvent SampleEvent() => new()
{
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
SourceSiteId = "plant-a",
Status = AuditStatus.Delivered,
HttpStatus = 200,
};
/// <summary>
/// Builds a tiny in-process test host that wires the export endpoint to a
/// stubbed <see cref="IAuditLogRepository"/>. Returns a ready-to-call
/// <see cref="HttpClient"/> and the repo substitute so the test can assert
/// on what the endpoint did.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { SampleEvent() }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
// The endpoint is AuditExport-gated (#23 M7-T15 Bundle G);
// the tests run as pre-authenticated principals built by
// FakeAuthHandler (everyone has the Admin role), which is
// one of AuditExportRoles, so the policy succeeds.
services.AddAuthentication(FakeAuthHandler.SchemeName)
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, _ => { });
// Use the real production policy wiring so the endpoint's
// updated AuditExport gate (#23 M7-T15 Bundle G) is what
// the tests exercise. The fake principal carries the
// "Admin" role, which AuditExportRoles permits.
services.AddScadaBridgeAuthorization();
services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapAuditExportEndpoints();
});
});
});
var host = await hostBuilder.StartAsync();
var client = host.GetTestClient();
return (client, repo, host);
}
[Fact]
public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition()
{
var (client, _, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Content-Type: text/csv (charset may or may not be present).
Assert.NotNull(response.Content.Headers.ContentType);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
// Content-Disposition: attachment with a *.csv filename.
ContentDispositionHeaderValue? disposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(disposition);
Assert.Equal("attachment", disposition!.DispositionType);
Assert.NotNull(disposition.FileName);
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
// Body starts with the header row and contains the sample row.
var body = await response.Content.ReadAsStringAsync();
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
}
}
[Fact]
public async Task ExportEndpoint_PassesFilterFromQueryString_ToService()
{
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var correlationId = Guid.NewGuid().ToString();
var executionId = Guid.NewGuid().ToString();
var parentExecutionId = Guid.NewGuid().ToString();
var url =
"/api/centralui/audit/export?" +
"channel=ApiOutbound&" +
"kind=ApiCall&" +
"status=Failed&" +
"site=plant-a&" +
"target=PaymentApi&" +
"actor=apikey-1&" +
$"correlationId={correlationId}&" +
$"executionId={executionId}&" +
$"parentExecutionId={parentExecutionId}&" +
"from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z";
var response = await client.GetAsync(url);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Read the body to ensure the streaming response is fully drained
// before we assert on the repo substitute (the test server flushes
// the endpoint pipeline on response read).
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
f.ExecutionId == Guid.Parse(executionId) &&
f.ParentExecutionId == Guid.Parse(parentExecutionId) &&
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter()
{
// Sanity: a bare GET (no params) yields a filter with every column null
// — i.e. an unconstrained export.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channels == null &&
f.Kinds == null &&
f.Statuses == null &&
f.SourceSiteIds == null &&
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
f.ExecutionId == null &&
f.ParentExecutionId == null &&
f.FromUtc == null &&
f.ToUtc == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored()
{
// Defensive parsing: a junk channel value MUST NOT 500 the export —
// mirrors the page-level query-string parser (#23 M7 Bundle D) which
// silently drops unrecognised values.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?channel=DefinitelyNotAChannel");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400)
// — mirrors the executionId / correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
/// <summary>
/// Test-only authentication handler that signs every request in as an Admin.
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
/// passes without spinning up the real cookie + LDAP pipeline.
/// </summary>
private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "FakeAuth";
public FakeAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "test-admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"),
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
[Fact]
public void ExportEndpoint_RouteIsRegistered()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAuthorization();
builder.Services.AddSingleton(Substitute.For<IAuditLogRepository>());
builder.Services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider watcher and the ConsoleLoggerProcessor thread.
using var app = builder.Build();
app.MapAuditExportEndpoints();
var endpoints = ((IEndpointRouteBuilder)app).DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
var export = endpoints.FirstOrDefault(e =>
e.RoutePattern.RawText == "/api/centralui/audit/export" &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") ?? false));
Assert.NotNull(export);
}
}
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-017. <c>POST /auth/logout</c> called
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> route also
/// signed the user out — either could be triggered cross-site to forcibly log
/// a user out. Logout is a state-changing authenticated action and must be
/// CSRF-protected: the POST keeps antiforgery enabled and the state-changing
/// GET route is removed.
/// </summary>
public class AuthEndpointsCsrfTests
{
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
// thread, which keep the test host process alive after the run.
using var app = builder.Build();
app.MapAuthEndpoints();
return ((IEndpointRouteBuilder)app).DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
}
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
=> endpoints.FirstOrDefault(e =>
e.RoutePattern.RawText == pattern &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
[Fact]
public void PostAuthLogout_DoesNotDisableAntiforgery()
{
var endpoints = BuildEndpoints();
var logout = Find(endpoints, "/auth/logout", "POST");
Assert.NotNull(logout);
// DisableAntiforgery() leaves an IAntiforgeryMetadata with
// RequiresValidation == false. A CSRF-protected POST has either no such
// metadata, or metadata that still requires validation.
var antiforgery = logout!.Metadata.GetMetadata<IAntiforgeryMetadata>();
Assert.True(antiforgery is null || antiforgery.RequiresValidation,
"POST /auth/logout must keep antiforgery validation enabled.");
}
[Fact]
public void GetLogout_StateChangingRoute_IsRemoved()
{
var endpoints = BuildEndpoints();
var getLogout = Find(endpoints, "/logout", "GET");
Assert.Null(getLogout);
}
[Fact]
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
{
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
// and intentional. This pins that the fix did not over-correct.
var endpoints = BuildEndpoints();
var login = Find(endpoints, "/auth/login", "POST");
Assert.NotNull(login);
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
Assert.NotNull(antiforgery);
Assert.False(antiforgery!.RequiresValidation);
}
}
@@ -0,0 +1,94 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-020. The Blazor circuit's
/// <c>CookieAuthenticationStateProvider</c> serves a frozen constructor-time
/// principal, so <c>SessionExpiry</c> could never observe a server-side cookie
/// expiry by polling the auth state. The fix adds <c>GET /auth/ping</c>, an
/// endpoint evaluated per HTTP request (where the cookie middleware re-validates
/// the cookie): it returns 200 while the session is live and 401 once the
/// cookie has lapsed, giving <c>SessionExpiry</c> a real signal to redirect on.
/// </summary>
public class AuthPingEndpointTests
{
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
// thread, which keep the test host process alive after the run.
using var app = builder.Build();
app.MapAuthEndpoints();
return ((IEndpointRouteBuilder)app).DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
}
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
=> endpoints.FirstOrDefault(e =>
e.RoutePattern.RawText == pattern &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
[Fact]
public void AuthPing_GetRoute_IsMapped()
{
var ping = Find(BuildEndpoints(), "/auth/ping", "GET");
Assert.NotNull(ping);
}
[Fact]
public async Task AuthPing_AnonymousUser_Returns401()
{
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity()) // not authenticated
};
await AuthEndpoints.HandlePing(context);
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
[Fact]
public async Task AuthPing_AuthenticatedUser_Returns200()
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "alice") }, authenticationType: "TestCookie");
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity)
};
await AuthEndpoints.HandlePing(context);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}
[Fact]
public void AuthPing_DoesNotTriggerCookieRedirect()
{
// The endpoint must NOT use RequireAuthorization(): that would make the
// cookie middleware answer an expired request with a 302 to /login,
// which a fetch() follows transparently and reads as a 200 login page —
// SessionExpiry would never see the expiry. The endpoint allows
// anonymous access and decides 200/401 itself.
var ping = Find(BuildEndpoints(), "/auth/ping", "GET");
Assert.NotNull(ping);
var authorize = ping!.Metadata
.GetOrderedMetadata<Microsoft.AspNetCore.Authorization.IAuthorizeData>();
Assert.Empty(authorize);
}
}
@@ -0,0 +1,88 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-024. Ten components each copy-pasted a
/// <c>GetCurrentUserAsync</c> helper using the magic string
/// <c>FindFirst("Username")</c>, and <c>NavMenu</c>/<c>Dashboard</c> used
/// <c>FindFirst("DisplayName")</c>. A rename of the claim type in
/// <see cref="JwtTokenService"/> (the single source of truth) would have
/// silently broken every call site. The shared
/// <see cref="ClaimsPrincipalExtensions"/> helpers now resolve the claim type
/// through the <c>JwtTokenService</c> constants.
/// </summary>
public class ClaimsPrincipalExtensionsTests
{
private static ClaimsPrincipal Principal(params Claim[] claims)
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
[Fact]
public void GetUsername_ResolvesTheJwtTokenServiceUsernameClaim()
{
var principal = Principal(
new Claim(JwtTokenService.UsernameClaimType, "alice"));
Assert.Equal("alice", principal.GetUsername());
}
[Fact]
public void GetUsername_FallsBackToUnknown_WhenClaimAbsent()
{
var principal = Principal();
Assert.Equal(ClaimsPrincipalExtensions.UnknownUser, principal.GetUsername());
}
[Fact]
public void GetDisplayName_ResolvesTheJwtTokenServiceDisplayNameClaim()
{
var principal = Principal(
new Claim(JwtTokenService.DisplayNameClaimType, "Alice Anderson"));
Assert.Equal("Alice Anderson", principal.GetDisplayName());
}
[Fact]
public void GetDisplayName_IsNull_WhenClaimAbsent()
{
Assert.Null(Principal().GetDisplayName());
}
[Fact]
public async Task GetCurrentUsernameAsync_ReadsUsernameFromAuthState()
{
var principal = Principal(
new Claim(JwtTokenService.UsernameClaimType, "bob"));
var provider = new StubAuthStateProvider(
new AuthenticationState(principal));
Assert.Equal("bob", await provider.GetCurrentUsernameAsync());
}
[Fact]
public void Username_LookupTracksAJwtTokenServiceRename()
{
// The lookup must NOT use a hard-coded "Username" literal: if the
// constant's *value* is ever changed, the helper must follow it. Build a
// principal whose claim carries the JwtTokenService constant's current
// value and confirm the helper finds it via that same constant.
var principal = Principal(
new Claim(JwtTokenService.UsernameClaimType, "carol"));
Assert.Equal("carol",
principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
Assert.Equal("carol", principal.GetUsername());
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
}
@@ -0,0 +1,79 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-004. The provider used to read
/// <see cref="IHttpContextAccessor.HttpContext"/> on every call; once the Blazor
/// circuit is established that context is gone, so later re-evaluations saw an
/// unauthenticated principal. The provider must snapshot the principal once at
/// construction (during the initial HTTP request) and serve it for the circuit.
/// </summary>
public class CookieAuthenticationStateProviderTests
{
private static ClaimsPrincipal AuthenticatedUser(string name)
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, name) },
authenticationType: "TestCookie");
return new ClaimsPrincipal(identity);
}
[Fact]
public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent()
{
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("alice") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
var state = await provider.GetAuthenticationStateAsync();
Assert.True(state.User.Identity?.IsAuthenticated);
Assert.Equal("alice", state.User.Identity?.Name);
}
[Fact]
public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone()
{
// The circuit is built during the HTTP request: HttpContext is valid then.
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("bob") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
// After the request completes, IHttpContextAccessor.HttpContext is null for
// the life of the long-lived SignalR circuit.
accessor.HttpContext = null;
var state = await provider.GetAuthenticationStateAsync();
// The pre-fix implementation returned an anonymous principal here.
Assert.True(state.User.Identity?.IsAuthenticated);
Assert.Equal("bob", state.User.Identity?.Name);
}
[Fact]
public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext()
{
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("carol") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
// A stale/foreign context leaking through the AsyncLocal accessor must NOT
// change what this circuit's provider reports.
accessor.HttpContext = new DefaultHttpContext { User = AuthenticatedUser("intruder") };
var first = await provider.GetAuthenticationStateAsync();
var second = await provider.GetAuthenticationStateAsync();
Assert.Equal("carol", first.User.Identity?.Name);
Assert.Equal("carol", second.User.Identity?.Name);
}
}
@@ -0,0 +1,88 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-020 and CentralUI-025. <c>SessionExpiry</c>
/// used to poll the Blazor <c>AuthenticationStateProvider</c>, which (via
/// <c>CookieAuthenticationStateProvider</c>) serves a frozen constructor-time
/// principal — so the polled state could never become "expired" and the
/// idle-logout redirect never fired. The component now polls the server
/// <c>GET /auth/ping</c> endpoint, which reflects the live cookie session: a
/// 401 response triggers a redirect to <c>/login</c>. These tests exercise that
/// redirect path directly (CentralUI-025: the path was previously untested).
/// </summary>
public class SessionExpiryComponentTests : BunitContext
{
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
[Fact]
public async Task CheckSession_ExpiredSession_RedirectsToLogin()
{
// The server reports the cookie has lapsed: ping returns HTTP 401.
var module = JSInterop.SetupModule(ModulePath);
module.Setup<int>("ping", "/auth/ping").SetResult(401);
var nav = Services.GetRequiredService<NavigationManager>();
var cut = Render<SessionExpiry>();
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
Assert.EndsWith("/login", nav.Uri);
}
[Fact]
public async Task CheckSession_LiveSession_DoesNotRedirect()
{
// The server reports the session is still valid: ping returns HTTP 200.
var module = JSInterop.SetupModule(ModulePath);
module.Setup<int>("ping", "/auth/ping").SetResult(200);
var nav = Services.GetRequiredService<NavigationManager>();
var before = nav.Uri;
var cut = Render<SessionExpiry>();
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
Assert.Equal(before, nav.Uri);
Assert.DoesNotContain("/login", nav.Uri);
}
[Fact]
public async Task CheckSession_TransientNetworkFailure_DoesNotRedirect()
{
// A network blip surfaces as status 0 — inconclusive. The component must
// NOT log an authenticated user out on a transient failure.
var module = JSInterop.SetupModule(ModulePath);
module.Setup<int>("ping", "/auth/ping").SetResult(0);
var nav = Services.GetRequiredService<NavigationManager>();
var before = nav.Uri;
var cut = Render<SessionExpiry>();
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
Assert.Equal(before, nav.Uri);
}
[Fact]
public async Task CheckSession_OnLoginPage_DoesNotPingOrRedirect()
{
// On /login the component must neither poll nor redirect (a /login →
// /login redirect would loop). JSInterop is left in Strict mode with no
// module setup, so any ping call would throw and fail the test.
var nav = (BunitNavigationManager)Services
.GetRequiredService<NavigationManager>();
nav.NavigateTo("login");
var cut = Render<SessionExpiry>();
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
// No JS module import was attempted and the URL is unchanged.
Assert.EndsWith("/login", nav.Uri);
}
}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authentication;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-005. <c>AuthEndpoints</c> previously stamped a
/// fixed <c>expires_at = UtcNow + 30 min</c> claim and a 30-minute absolute cookie
/// <c>ExpiresUtc</c> with no sliding refresh, contradicting the documented
/// "sliding refresh, 30-minute idle timeout" policy. The login handler must now
/// build <see cref="AuthenticationProperties"/> that let the cookie middleware
/// own expiry (sliding window) rather than imposing a contradictory fixed
/// absolute cap.
/// </summary>
public class SessionExpiryPolicyTests
{
[Fact]
public void BuildSignInProperties_DoesNotSetFixedAbsoluteExpiry()
{
var props = AuthEndpoints.BuildSignInProperties();
// A fixed ExpiresUtc would re-introduce the hard 30-minute cap that
// overrides the middleware's sliding window. Expiry must be owned by
// the cookie middleware (ExpireTimeSpan + SlidingExpiration).
Assert.Null(props.ExpiresUtc);
}
[Fact]
public void BuildSignInProperties_IsPersistent()
{
var props = AuthEndpoints.BuildSignInProperties();
Assert.True(props.IsPersistent);
}
[Fact]
public void BuildSignInProperties_AllowsSlidingRefresh()
{
var props = AuthEndpoints.BuildSignInProperties();
// AllowRefresh left null/true lets the cookie middleware slide the
// expiry on activity. A false value would freeze the session to an
// absolute cap — the bug this finding pins.
Assert.NotEqual(false, props.AllowRefresh);
}
}
@@ -0,0 +1,93 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are
/// written as <c>SiteId</c> claims at login but were never read — Deployment
/// pages listed and acted on every site. <see cref="SiteScopeService"/> is the
/// shared helper that reads those claims; these tests pin its behaviour.
/// </summary>
public class SiteScopeServiceTests
{
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
private static SiteScopeService ForUser(params Claim[] claims)
{
var identity = new ClaimsIdentity(claims, authenticationType: "TestCookie");
return new SiteScopeService(new StubAuthStateProvider(new ClaimsPrincipal(identity)));
}
private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role);
private static Claim SiteClaim(int id) => new(JwtTokenService.SiteIdClaimType, id.ToString());
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
[Fact]
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Deployment"));
Assert.True(await svc.IsSystemWideAsync());
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
[Fact]
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
{
var svc = ForUser(Role("Deployment"));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
[Fact]
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
{
// Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2.
var svc = ForUser(Role("Deployment"), SiteClaim(1), SiteClaim(3));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4));
Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x));
}
[Fact]
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
{
var svc = ForUser(Role("Deployment"), SiteClaim(5));
Assert.True(await svc.IsSiteAllowedAsync(5));
Assert.False(await svc.IsSiteAllowedAsync(6));
}
[Fact]
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
{
var svc = ForUser(Role("Deployment"), SiteClaim(7), SiteClaim(9));
Assert.False(await svc.IsSystemWideAsync());
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
}
[Fact]
public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
{
var svc = ForUser(Role("Deployment"));
Assert.True(await svc.IsSiteAllowedAsync(1));
Assert.True(await svc.IsSiteAllowedAsync(999));
}
}
@@ -0,0 +1,75 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for CentralUI Blazor components.
/// Verifies that pages render their expected markup structure.
/// </summary>
public class ComponentRenderingTests : BunitContext
{
[Fact]
public void LoginPage_RendersForm_WithUsernameAndPasswordFields()
{
var cut = Render<Login>();
// Verify the form action
var form = cut.Find("form");
Assert.Equal("/auth/login", form.GetAttribute("action"));
// Verify username field
var usernameInput = cut.Find("input#username");
Assert.Equal("text", usernameInput.GetAttribute("type"));
Assert.Equal("username", usernameInput.GetAttribute("name"));
// Verify password field
var passwordInput = cut.Find("input#password");
Assert.Equal("password", passwordInput.GetAttribute("type"));
Assert.Equal("password", passwordInput.GetAttribute("name"));
// Verify submit button
var submitButton = cut.Find("button[type='submit']");
Assert.Contains("Sign In", submitButton.TextContent);
}
[Fact]
public void LoginPage_WithoutError_DoesNotRenderAlert()
{
var cut = Render<Login>();
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("div.alert.alert-danger"));
}
[Fact]
public void Dashboard_RequiresAuthorizeAttribute()
{
var authorizeAttrs = typeof(Dashboard)
.GetCustomAttributes(typeof(AuthorizeAttribute), true);
Assert.NotEmpty(authorizeAttrs);
}
[Fact]
public void TemplateEditor_RequiresDesignPolicy()
{
var authorizeAttrs = typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.Templates)
.GetCustomAttributes(typeof(AuthorizeAttribute), true);
Assert.NotEmpty(authorizeAttrs);
var attr = (AuthorizeAttribute)authorizeAttrs[0];
Assert.Equal("RequireDesign", attr.Policy);
}
[Fact]
public void LoginPage_RendersScadaBridgeTitle()
{
var cut = Render<Login>();
var title = cut.Find("h4.card-title");
Assert.Equal("ScadaBridge", title.TextContent);
}
}
@@ -0,0 +1,381 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#23 M7 Bundle C / M7-T4..T8).
///
/// The drawer is a child component opened from the Audit Log page when a grid row
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
/// the <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
/// component, which since the recent refactor owns the channel-aware bodies
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
/// Request/Response, and conditional action buttons.
///
/// Tests pin the behaviours we cannot lose without breaking the spec:
/// offcanvas open/close, header rendering, and that the event body is handed
/// off to <see cref="AuditEventDetail"/>.
/// </summary>
public class AuditDrilldownDrawerTests : BunitContext
{
public AuditDrilldownDrawerTests()
{
// Default to Loose so the cURL clipboard call does not blow up tests
// that don't exercise it. Tests that need to assert interop calls flip
// to Strict and configure their own setups.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
=> new()
{
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
Actor = "tester",
Target = target,
Status = status,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
DurationMs = 42,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
Extra = extra,
};
[Fact]
public void Drawer_RendersField_OccurredAtUtc()
{
var ev = MakeEvent();
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
// OccurredAtUtc renders ISO-8601 round-trip ("o" format). The
// year+time fragment is sufficient evidence — the full ISO string
// changes shape with locale-dependent formatting in some envs.
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
[Fact]
public void Drawer_JsonRequestSummary_PrettyPrinted_Indented()
{
// A single-line JSON body should be re-emitted indented.
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
// Pretty-print writes one property per line — the " \"a\":" prefix
// proves indentation. We don't pin the exact bytes; we pin "indented"
// by looking for newline-prefixed property lines.
Assert.Contains("data-test=\"request-body\"", cut.Markup);
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
Assert.Matches(@"\n\s+""b"":\s*""two""", cut.Markup);
}
[Fact]
public void Drawer_NonJsonRequestSummary_RenderedVerbatim()
{
// Non-JSON content (e.g. plain text or invalid JSON) must round-trip
// exactly — the drawer should not attempt to "fix" or rewrite it.
var ev = MakeEvent(requestSummary: "not really json {{}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("not really json {{}", cut.Markup);
}
[Fact]
public void Drawer_DbOutboundChannel_RendersSqlBlock()
{
// DbOutbound payloads carry a {sql, parameters} JSON shape. The drawer
// renders sql inside a code block with language-sql class (CSS-only,
// no JS highlighter) and lists the parameters in a definition list.
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("language-sql", cut.Markup);
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
// Parameter dl shows both keys.
Assert.Contains("p1", cut.Markup);
Assert.Contains("p2", cut.Markup);
Assert.Contains("42", cut.Markup);
Assert.Contains("abc", cut.Markup);
}
[Fact]
public void Drawer_ApiOutbound_ShowsCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void Drawer_NotApiChannel_HidesCopyAsCurlButton()
{
// Notification is neither an API outbound nor inbound — no cURL.
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void Drawer_NullCorrelationId_HidesShowAllButton()
{
var ev = MakeEvent(correlationId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void Drawer_RedactedBody_ShowsRedactionBadge()
{
// The redaction sentinel is the literal string `<redacted>` (or
// `<redacted: redactor error>`) — the drawer must flag it visibly.
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void Drawer_NonRedactedBody_HidesBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var ev = MakeEvent(correlationId: corr);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"show-all-events\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains("/audit/log?correlationId=", nav.Uri);
Assert.Contains(corr.ToString(), nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-this-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
[Fact]
public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void ViewParentExecution_Navigates_WithExecutionIdQueryString()
{
// A routed (child) row drills in to its spawner: the "View parent
// execution" action navigates to /audit/log?executionId={ParentExecutionId}
// so the user sees the spawner execution's rows.
var parent = Guid.Parse("eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa");
var ev = MakeEvent(parentExecutionId: parent);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-parent-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
// The "View execution chain" action opens the tree view rooted at the
// chain containing this row's ExecutionId.
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
// Set up Strict mode interop so the call must match exactly.
JSInterop.Mode = JSRuntimeMode.Strict;
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
// Build an event with a {headers, body} RequestSummary so the cURL
// builder has material to fold in.
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
target: "https://example.test/api/v1/widgets",
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
// Bunit's JSRuntimeInvocationDictionary is keyed by identifier
// (string) — we enumerate it instead of indexing by int.
var calls = clipboardCall.Invocations.ToList();
Assert.NotEmpty(calls);
var argString = (string)calls[0].Arguments[0]!;
Assert.StartsWith("curl ", argString);
Assert.Contains("https://example.test/api/v1/widgets", argString);
Assert.Contains("Content-Type: application/json", argString);
}
}
@@ -0,0 +1,320 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditEventDetail"/> — the reusable single-row
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> (Task 1 of the
/// Execution-Tree Node Detail Modal feature).
///
/// These tests render the detail component directly (not via the drawer) and
/// pin the contract the drawer — and any future modal host — relies on:
/// the read-only field block, the conditional Error/Request/Response/Extra
/// sections, the redaction badge, channel-aware body rendering, and the
/// action buttons. All <c>data-test</c> values must match the originals so the
/// existing <see cref="AuditDrilldownDrawer"/> selectors keep resolving.
/// </summary>
public class AuditEventDetailTests : BunitContext
{
public AuditEventDetailTests()
{
// Loose so the cURL clipboard call does not blow up tests that do not
// exercise it. The clipboard test flips to Strict itself.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
=> new()
{
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
Actor = "tester",
Target = target,
Status = status,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
DurationMs = 42,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
Extra = extra,
};
[Fact]
public void RendersFieldBlock()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"drawer-fields\"", cut.Markup);
Assert.Contains("data-test=\"field-Channel\"", cut.Markup);
Assert.Contains("data-test=\"field-Status\"", cut.Markup);
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
[Fact]
public void RendersSourceNodeField_BetweenSiteAndInstance()
{
// SourceNode is rendered as a sibling row directly under SourceSiteId
// so the popup reads "site → node → instance → script" in provenance
// order. Populated case.
var ev = MakeEvent() with { SourceNode = "node-a" };
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"field-SourceNode\"", cut.Markup);
Assert.Contains("node-a", cut.Markup);
// Ordering: SourceSiteId appears before SourceNode, which appears
// before SourceInstanceId.
var siteIdx = cut.Markup.IndexOf("data-test=\"field-SourceSiteId\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"field-SourceNode\"", StringComparison.Ordinal);
var instanceIdx = cut.Markup.IndexOf("data-test=\"field-SourceInstanceId\"", StringComparison.Ordinal);
Assert.True(siteIdx > 0 && nodeIdx > siteIdx && instanceIdx > nodeIdx,
$"Expected SourceSiteId < SourceNode < SourceInstanceId; got {siteIdx}, {nodeIdx}, {instanceIdx}");
}
[Fact]
public void RendersSourceNodeField_AsDashWhenNull()
{
// Null SourceNode (e.g. central direct-write row pre-feature, or a
// reconciled row from a retired node) renders as the em-dash, same
// convention as the sibling provenance fields.
var ev = MakeEvent(); // SourceNode left at default null
Assert.Null(ev.SourceNode);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"field-SourceNode\"", cut.Markup);
// The field is present and renders the em-dash placeholder.
Assert.Contains(">—<", cut.Markup);
}
[Fact]
public void ErrorSection_RendersWhenErrorPresent()
{
var ev = MakeEvent(
status: AuditStatus.Parked,
errorMessage: "boom",
errorDetail: "stack trace here");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-error\"", cut.Markup);
Assert.Contains("boom", cut.Markup);
Assert.Contains("stack trace here", cut.Markup);
}
[Fact]
public void ErrorSection_HiddenWhenNoError()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup);
}
[Fact]
public void RequestSection_PrettyPrintsJson()
{
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-request\"", cut.Markup);
Assert.Contains("data-test=\"request-body\"", cut.Markup);
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
}
[Fact]
public void ResponseSection_RendersWhenPresent()
{
var ev = MakeEvent(responseSummary: "{\"ok\":true}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-response\"", cut.Markup);
Assert.Contains("data-test=\"response-body\"", cut.Markup);
}
[Fact]
public void ExtraSection_RendersWhenPresent()
{
var ev = MakeEvent(extra: "{\"note\":\"hi\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-extra\"", cut.Markup);
}
[Fact]
public void RedactedBody_ShowsRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void NonRedactedBody_HidesRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void DbOutboundChannel_RendersSqlBlock()
{
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("language-sql", cut.Markup);
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
Assert.Contains("data-test=\"sql-parameters\"", cut.Markup);
}
[Fact]
public void ApiChannel_ShowsCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NonApiChannel_HidesCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NullCorrelationId_HidesShowAllButton()
{
var ev = MakeEvent(correlationId: null);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void NonNullCorrelationId_ShowsShowAllButton()
{
var ev = MakeEvent(correlationId: Guid.NewGuid());
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void ExecutionButtons_ConditionalOnExecutionIds()
{
var ev = MakeEvent(
executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"),
parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var ev = MakeEvent(correlationId: corr);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"show-all-events\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
JSInterop.Mode = JSRuntimeMode.Strict;
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
target: "https://example.test/api/v1/widgets",
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
var calls = clipboardCall.Invocations.ToList();
Assert.NotEmpty(calls);
var argString = (string)calls[0].Arguments[0]!;
Assert.StartsWith("curl ", argString);
Assert.Contains("https://example.test/api/v1/widgets", argString);
}
}
@@ -0,0 +1,314 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
private readonly ISiteRepository _siteRepo;
private readonly IAuditLogQueryService _auditLogQueryService;
public AuditFilterBarTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
// Task 15: the Node multi-select pulls its options from
// IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
// returns the two central nodes the cluster uses; individual tests can
// override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
_auditLogQueryService = Substitute.For<IAuditLogQueryService>();
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
Services.AddSingleton(_auditLogQueryService);
}
[Fact]
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
{
var cut = Render<AuditFilterBar>();
// Each filter element is tagged with a stable data-test attribute so the test
// doesn't churn on cosmetic label changes.
var markers = new[]
{
"data-test=\"filter-channel\"",
"data-test=\"filter-kind\"",
"data-test=\"filter-status\"",
"data-test=\"filter-site\"",
"data-test=\"filter-node\"",
"data-test=\"filter-time-range\"",
"data-test=\"filter-custom-range\"",
"data-test=\"filter-instance\"",
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-parent-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
{
Assert.Contains(marker, cut.Markup);
}
}
[Fact]
public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target);
}
[Fact]
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{
// Channel is single-select: picking a second channel replaces the first
// rather than adding to it (the page filters one channel at a time).
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
// Selecting "All channels" clears the channel filter entirely.
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.Channels);
}
[Fact]
public void Channel_Narrows_Kind_Options_When_Selected()
{
var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind option is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>())
{
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds)
{
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Toggle Errors-only ON, leaving Status chips empty.
cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
Assert.NotNull(captured!.Statuses);
Assert.Equal(3, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
{
// Task 15: the Node filter pulls its option set from
// IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
// chip selection into AuditLogQueryFilter.SourceNodes.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// The bar marker plus the option checkboxes for the two cluster nodes
// are present after init (the constructor stubs return two nodes).
Assert.Contains("data-test=\"filter-node\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);
cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.SourceNodes);
Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{
// Task 9: multiple explicit Status chips all reach the filter — and they
// win over the Errors-only default.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.Statuses);
Assert.Equal(2, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Delivered, captured.Statuses);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Execution ID box; Apply must map it
// straight onto AuditLogQueryFilter.ExecutionId.
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ExecutionId);
}
[Fact]
public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Parent execution ID box; Apply must
// map it straight onto AuditLogQueryFilter.ParentExecutionId.
var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ParentExecutionId);
}
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// LastHour is the default preset; clicking Apply must collapse it to FromUtc.
var before = DateTime.UtcNow;
cut.Find("[data-test=\"filter-apply\"]").Click();
var after = DateTime.UtcNow;
Assert.NotNull(captured);
Assert.NotNull(captured!.FromUtc);
// FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment.
var expectedLow = before.AddHours(-1).AddSeconds(-1);
var expectedHigh = after.AddHours(-1).AddSeconds(1);
Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh);
}
}
@@ -0,0 +1,380 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
/// renders 10 columns, paginates via keyset (passing the last row's
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
/// that Bundle C wires to the drilldown drawer, and styles non-success status
/// rows with an error-coded badge.
/// </summary>
public class AuditResultsGridTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
};
public AuditResultsGridTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
// sessionStorage load). Loose mode lets those unconfigured calls no-op
// — auditGrid.load returns null (no prior state) unless a test sets up
// an explicit JSInterop.Setup to return a stored payload.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void Render_TenColumns_FromStubService()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// 10 column headers per Component-AuditLog.md §10.
var expectedHeaders = new[]
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
foreach (var header in expectedHeaders)
{
Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup);
}
}
[Fact]
public void Click_NextPage_CallsService_WithCursor_OfLastRow()
{
// First page: two rows, descending by OccurredAtUtc. The grid must pass the
// LAST row (the older one) back as the keyset cursor for the next page.
var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered);
var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed);
StubPage(new[] { first, second });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
cut.Find("[data-test=\"grid-next-page\"]").Click();
// Two service calls: initial + next.
Assert.Equal(2, _calls.Count);
var nextCall = _calls[1];
Assert.NotNull(nextCall.Paging);
Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc);
Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
}
[Fact]
public void Click_Row_RaisesOnRowSelected()
{
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
StubPage(new[] { target });
AuditEvent? captured = null;
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
Assert.NotNull(captured);
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
Assert.Contains("data-test=\"col-header-Node\"", cut.Markup);
// The header order must place Node between Site and Channel.
var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal);
var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal);
Assert.True(siteIdx < nodeIdx, "Node column must follow Site.");
Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form.
Assert.Equal("abcdef01", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ParentExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
}
[Fact]
public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form — mirrors ExecutionId.
Assert.Equal("fedcba98", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ParentExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed);
var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered);
StubPage(new[] { delivered, failed });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Failed badge => bg-danger (red). Delivered => bg-success (green).
var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]");
Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty);
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
// load/apply/persist logic that the JS callbacks drive: graceful handling
// of stored orders, the reorder slot-move maths, and the resize minimum.
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "ExecutionId", "ParentExecutionId",
"DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
foreach (var key in DefaultOrder)
{
// Each <th> carries the stable drag key and a resize handle.
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
}
}
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
// Status + Site move to the front; the omitted columns still render,
// appended in default order — Status precedes Site precedes Channel.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
// No column is dropped — all ten headers are present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
// The new order was persisted to sessionStorage under the order key.
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
var save = JSInterop.Invocations
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
Assert.Contains("Status", (string)save.Arguments[1]!);
}
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A drag that would shrink the column to 10px must clamp to the 64px floor.
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
// The clamped width is reflected as the --audit-col-width custom property.
Assert.Contains("--audit-col-width: 64px", cut.Markup);
// The width was persisted to sessionStorage under the widths key.
Assert.Contains(JSInterop.Invocations,
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
// A stale persisted order naming a removed column ("LegacyCol") plus a
// subset of real columns — the unknown key must be dropped and the
// omitted real columns appended in default order, never throwing.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult((string?)null);
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Restored order applied: Status then Site at the front.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
// The unknown key produced no header and did not break rendering.
Assert.DoesNotContain("LegacyCol", cut.Markup);
// All ten real columns still present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult((string?)null);
// A width for a real column and one for a removed column.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The valid column's width was applied; the stale one silently ignored.
Assert.Contains("--audit-col-width: 220px", cut.Markup);
Assert.DoesNotContain("300px", cut.Markup);
}
}
@@ -0,0 +1,299 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// an <c>ExecutionId</c> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// Tests pin the behaviours the spec cannot lose: load-on-open-transition,
/// the four data states (multi-row list, single-row straight-to-detail,
/// zero-row empty, query-failure error), and that closing raises OnClose.
/// </summary>
public class ExecutionDetailModalTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
public ExecutionDetailModalTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// AuditEventDetail (the per-row detail body) owns a clipboard interop
// call. Loose mode lets that no-op for tests that don't exercise it.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
Guid executionId,
AuditStatus status = AuditStatus.Delivered,
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
string? target = "demo-target")
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
Status = status,
ExecutionId = executionId,
SourceSiteId = "plant-a",
Target = target,
Actor = "tester",
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
};
private void StubRows(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void ClosedModal_RendersNothing_AndDoesNotQuery()
{
StubRows(new[] { MakeEvent(Guid.NewGuid()) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, Guid.NewGuid())
.Add(c => c.IsOpen, false));
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
Assert.Empty(_calls);
}
[Fact]
public void OpenTransition_QueriesByExecutionId_WithPageSize100()
{
var executionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, false));
// Closed on first render — no query yet.
Assert.Empty(_calls);
// Flip open: the modal loads exactly once for the open transition.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
Assert.Equal(executionId, _calls[0].Filter.ExecutionId);
Assert.NotNull(_calls[0].Paging);
Assert.Equal(100, _calls[0].Paging!.PageSize);
}
[Fact]
public void StillOpen_NonOpenParameterChange_DoesNotRequery()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
Assert.Single(_calls);
// A parameter set that does NOT flip IsOpen must not re-query.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
}
[Fact]
public void MultiRow_RendersListView_WithOneButtonPerRow()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// List view: a row button per audit row, keyed by EventId.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]"));
// Not in detail view yet — no shared detail body rendered.
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void MultiRow_ClickRow_ShowsAuditEventDetail()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]").Click();
// The shared AuditEventDetail body is now rendered (its field list).
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// And a Back control to return to the list.
Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void MultiRow_BackControl_ReturnsToList()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]").Click();
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
cut.Find("[data-test=\"execution-detail-back\"]").Click();
// Back in the list view: row buttons present, detail body gone.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void SingleRow_OpensStraightToDetail_NoBackControl()
{
var executionId = Guid.NewGuid();
var only = MakeEvent(executionId);
StubRows(new[] { only });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// Straight to detail — the shared body is rendered without a click.
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// Nothing to go back to: the Back control is hidden for a single row.
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void ZeroRow_ShowsFriendlyEmptyState()
{
var executionId = Guid.NewGuid();
StubRows(Array.Empty<AuditEvent>());
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var empty = cut.Find("[data-test=\"execution-detail-empty\"]");
Assert.Contains("This execution emitted no audit rows.", empty.TextContent);
}
[Fact]
public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow()
{
var executionId = Guid.NewGuid();
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
// Rendering with IsOpen=true must not throw — the modal degrades to an
// inline error banner rather than killing the SignalR circuit.
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var error = cut.Find("[data-test=\"execution-detail-error\"]");
Assert.Contains("db is down", error.TextContent);
}
[Fact]
public void CloseButton_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-close\"]").Click();
Assert.True(closed);
}
[Fact]
public void BackdropClick_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-backdrop\"]").Click();
Assert.True(closed);
}
[Fact]
public void EscapeKey_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-modal\"]").KeyDown("Escape");
Assert.True(closed);
}
[Fact]
public void Header_ShowsShortExecutionId_AndRowCount()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var modal = cut.Find("[data-test=\"execution-detail-modal\"]");
// Short id (first 8 hex of the "N" form) appears in the header.
Assert.Contains("abcdef01", modal.TextContent);
// Row count surfaces in the header chrome.
Assert.Contains("3", modal.TextContent);
}
}
@@ -0,0 +1,299 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
/// feature, Task 10). The component takes the FLAT
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
/// presentation, the arrived-from highlight, node-click navigation, node
/// double-click raising/bubbling <see cref="ExecutionTreeNode.ExecutionId"/> via
/// <c>OnNodeActivated</c>, and cycle-safety (a corrupt flat list must not
/// infinite-loop).
/// </summary>
public class ExecutionTreeTests : BunitContext
{
private static ExecutionTreeNode Node(
Guid executionId,
Guid? parentExecutionId,
int rowCount = 2,
string? site = "plant-a",
string? instance = "boiler-3")
=> new(
executionId,
parentExecutionId,
rowCount,
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
rowCount == 0 ? null : site,
rowCount == 0 ? null : instance,
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
[Fact]
public void SingleNode_RendersOneTreeNode()
{
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode> { Node(id, null) };
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, id));
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
}
[Fact]
public void MultiLevel_AssemblesTree_FromFlatList()
{
// root → child → grandchild — a deliberately shuffled flat list so the
// component must reconstruct parent/child links rather than rely on
// input ordering.
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000");
var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(grandchild, child),
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// All three executions render as nodes.
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
// The root must appear before the child, and the child before the
// grandchild — recursive depth-first rendering preserves ancestry.
var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal);
var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal);
var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal);
Assert.True(rootIdx < childIdx, "root must render before child");
Assert.True(childIdx < grandIdx, "child must render before grandchild");
}
[Fact]
public void StubNode_RendersStubMarker()
{
// A stub parent (RowCount = 0) referenced by a real child must still
// render, visibly marked as "no audited actions".
var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000");
var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(stubParent, null, rowCount: 0),
Node(child, stubParent),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup);
Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup);
Assert.Contains("no audited actions", cut.Markup);
}
[Fact]
public void ArrivedFromNode_IsVisuallyHighlighted()
{
var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// The arrived-from node carries the highlight marker; a non-arrived
// sibling does not.
var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]");
Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class"));
var other = cut.Find($"[data-test=\"tree-node-{root}\"]");
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
{
// Each node's id is a real <a href> deep link — clicking it lands on
// the Audit Log filtered to that execution's rows. A genuine anchor
// (rather than an @onclick navigate) keeps the link middle-click /
// open-in-new-tab friendly, matching the rest of the Audit UI.
var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222");
var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]");
Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href"));
var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]");
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
}
[Fact]
public void EmptyNodeList_RendersNothingWithoutThrowing()
{
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
.Add(c => c.ArrivedFromExecutionId, Guid.NewGuid()));
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
}
[Fact]
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
{
// Defensive: a corrupt flat list where A→B and B→A must not hang the
// renderer. Each execution is rendered at most once.
var a = Guid.Parse("a0000000-0000-0000-0000-000000000000");
var b = Guid.Parse("b0000000-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(a, b),
Node(b, a),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, a));
// Both render exactly once — no runaway recursion.
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\""));
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
}
[Fact]
public void ToggleExpand_CollapsesAndReExpandsChildSubtree()
{
// root → child → grandchild. Clicking the root's toggle collapses its
// subtree (the child node disappears); clicking it again re-expands.
var root = Guid.Parse("aaaaaaaa-3333-3333-3333-333333333333");
var child = Guid.Parse("bbbbbbbb-3333-3333-3333-333333333333");
var grandchild = Guid.Parse("cccccccc-3333-3333-3333-333333333333");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
// All nodes start expanded — the whole chain is visible on arrival.
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
var toggle = cut.Find($"[data-test=\"tree-toggle-{root}\"]");
Assert.Equal("true", toggle.GetAttribute("aria-expanded"));
// Collapse: the child (and its descendants) must disappear.
toggle.Click();
Assert.DoesNotContain($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"false",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
// Re-expand: the child subtree reappears.
cut.Find($"[data-test=\"tree-toggle-{root}\"]").Click();
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"true",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
}
[Fact]
public void DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId()
{
// Double-clicking a node's body raises OnNodeActivated carrying that
// node's ExecutionId — the affordance a later task uses to open the
// node detail modal.
var root = Guid.Parse("aaaaaaaa-4444-4444-4444-444444444444");
var child = Guid.Parse("bbbbbbbb-4444-4444-4444-444444444444");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root)
.Add(c => c.OnNodeActivated, (Guid id) => activated = id));
var rootBody = cut.Find($"[data-test=\"tree-node-{root}\"] .execution-tree-body");
rootBody.DoubleClick();
Assert.Equal(root, activated);
}
[Fact]
public void DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot()
{
// root → child → grandchild. Double-clicking a deeply nested node's
// body invokes the SAME root-supplied callback — the EventCallback is
// threaded unchanged down every recursive ExecutionTree instance.
var root = Guid.Parse("aaaaaaaa-5555-5555-5555-555555555555");
var child = Guid.Parse("bbbbbbbb-5555-5555-5555-555555555555");
var grandchild = Guid.Parse("cccccccc-5555-5555-5555-555555555555");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root)
.Add(c => c.OnNodeActivated, (Guid id) => activated = id));
// Double-click the grandchild (two recursion levels deep).
cut.Find($"[data-test=\"tree-node-{grandchild}\"] .execution-tree-body").DoubleClick();
Assert.Equal(grandchild, activated);
// And the child (one level deep) — both reach the root's callback.
cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick();
Assert.Equal(child, activated);
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0, idx = 0;
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
{
count++;
idx += needle.Length;
}
return count;
}
}
@@ -0,0 +1,158 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
/// </list>
/// </summary>
public class AuditKpiTilesTests : BunitContext
{
private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — these are the contract for both
// tests and any future Playwright sweep.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains("120", cut.Markup); // volume
Assert.Contains("7", cut.Markup); // backlog
}
[Fact]
public void ErrorRate_Computed_From_Total_AndErrors()
{
// 5 errors out of 100 → 5.0%.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
.Add(c => c.IsAvailable, true));
Assert.Contains("5.0%", cut.Markup);
}
[Fact]
public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
{
// Total = 0 → naïve division would throw or yield NaN. The tile must
// render "0.0%" instead (zero events means zero errors too — a real
// signal, not an unavailability marker).
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0))
.Add(c => c.IsAvailable, true));
Assert.Contains("0.0%", cut.Markup);
// And the volume tile shows "0", not an em dash — the snapshot itself
// is available; the system was just quiet for the hour.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "DB connection refused"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Audit KPIs unavailable", cut.Markup);
Assert.Contains("DB connection refused", cut.Markup);
}
[Fact]
public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
.Add(c => c.IsAvailable, true));
// bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
tile.Click();
// Spec: error-rate tile drills into ?status=Failed.
Assert.Contains("/audit/log?status=Failed", nav.Uri);
}
[Fact]
public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-volume\"]");
tile.Click();
// Unfiltered /audit/log — no query string.
Assert.EndsWith("/audit/log", nav.Uri);
}
[Fact]
public void BacklogTile_Click_NavigatesToAuditLog()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]");
tile.Click();
Assert.EndsWith("/audit/log", nav.Uri);
}
[Fact]
public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
{
// 5% is < 10% → warning border, not danger.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void HighErrorRate_GetsDangerBorder()
{
// 25% is > 10% → danger border.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
}
@@ -0,0 +1,177 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Tile values render the snapshot's counters.</item>
/// <item>Threshold borders fire correctly — danger on Parked &gt; 0, warning
/// on Stuck &gt; 0, none when those counts are zero, none on Buffered.</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
/// </list>
/// </summary>
public class SiteCallKpiTilesTests : BunitContext
{
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
new(
CorrelationId: "k",
Success: true,
ErrorMessage: null,
BufferedCount: buffered,
ParkedCount: parked,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: stuck);
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — the contract for both these tests
// and any future Playwright sweep.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains(">120<", cut.Markup); // buffered
Assert.Contains(">7<", cut.Markup); // stuck
Assert.Contains(">3<", cut.Markup); // parked
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "site call repository unavailable"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
}
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
// Warning, not danger — Stuck is the softer signal.
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
// A non-zero buffer is normal operation — the Buffered tile is a plain
// count tile and never gets a danger/warning border.
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
var cls = tile.GetAttribute("class") ?? string.Empty;
Assert.DoesNotContain("border-danger", cls);
Assert.DoesNotContain("border-warning", cls);
}
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
tile.Click();
// Unfiltered /site-calls/report — no query string.
Assert.EndsWith("/site-calls/report", nav.Uri);
}
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
tile.Click();
// Spec: Stuck tile drills into the report's "stuck only" filter.
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
tile.Click();
// Spec: Parked tile drills into ?status=Parked.
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
}
}
@@ -0,0 +1,111 @@
using System.Security.Claims;
using System.Text.Json;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using DataConnectionForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnectionForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
public class DataConnectionFormTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public DataConnectionFormTests()
{
Services.AddSingleton(_siteRepo);
AddTestAuth();
var sites = new List<Site> { new("Plant-A", "plant-a") { Id = 1 } };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private IRenderedComponent<DataConnectionForm> RenderForCreateSite(int siteId)
{
Services.GetRequiredService<NavigationManager>()
.NavigateTo($"/admin/connections/create?siteId={siteId}");
return Render<DataConnectionForm>();
}
[Fact]
public void NoProtocolDropdown_IsRendered()
{
var cut = RenderForCreateSite(1);
Assert.DoesNotContain("Custom", cut.Markup);
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
Assert.DoesNotContain(labels, l => l == "Protocol");
}
[Fact]
public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo()
{
var cut = RenderForCreateSite(1);
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("not-a-url");
// Name field (the first editable text input that is NOT the OPC URL).
// Site renders as a readonly plaintext input when locked — skip it.
cut.FindAll("input[type='text']")
.First(i => !i.HasAttribute("readonly")
&& (i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")))
.Change("My Connection");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
await _siteRepo.DidNotReceive().AddDataConnectionAsync(Arg.Any<DataConnection>());
Assert.Contains("Endpoint URL must be a valid", cut.Markup);
}
[Fact]
public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa()
{
DataConnection? captured = null;
await _siteRepo.AddDataConnectionAsync(
Arg.Do<DataConnection>(d => captured = d));
var cut = RenderForCreateSite(1);
// Name (skip readonly Site plaintext input)
cut.FindAll("input[type='text']")
.First(i => !i.HasAttribute("readonly")
&& (i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")))
.Change("PLC-1");
// Endpoint URL
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("opc.tcp://plant-a:4840");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
Assert.NotNull(captured);
Assert.Equal("OpcUa", captured!.Protocol);
Assert.NotNull(captured.PrimaryConfiguration);
using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
Assert.Equal("opc.tcp://plant-a:4840",
doc.RootElement.GetProperty("endpointUrl").GetString());
Assert.Equal(60000,
doc.RootElement.GetProperty("sessionTimeoutMs").GetInt32());
}
}
@@ -0,0 +1,164 @@
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.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using DataConnectionsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for the Connections page (Site → DataConnection tree).
/// Focuses on the Topology-style behaviors layered onto this page: always-show-empty
/// sites, search dimming, toolbar gating, and dual-route declaration.
/// </summary>
public class DataConnectionsPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public DataConnectionsPageTests()
{
Services.AddSingleton(_siteRepo);
// Satisfy the page's [Inject] IDialogService — the host that actually
// renders the dialog lives in MainLayout, not in bUnit's render scope.
Services.AddScoped<IDialogService, DialogService>();
AddTestAuth();
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private void SeedRepos(
IEnumerable<Site>? sites = null,
IEnumerable<DataConnection>? connections = null)
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
_siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(connections?.ToList() ?? new List<DataConnection>()));
}
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<DataConnectionsPage> cut, string label) =>
cut.FindAll(".tv-row")
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
?.QuerySelector(".tv-toggle");
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
SeedRepos();
var cut = Render<DataConnectionsPage>();
Assert.Contains("No sites configured", cut.Markup);
}
[Fact]
public void Renders_EmptySite_AsTopLevelNode()
{
// A site with no connections must still appear so it can be right-clicked
// to "Add Connection here".
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
var cut = Render<DataConnectionsPage>();
Assert.Contains("Plant-A", cut.Markup);
}
[Fact]
public void Renders_SiteConnection_Nesting()
{
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
connections: new[]
{
new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 }
});
var cut = Render<DataConnectionsPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
Assert.Contains("PLC-1", cut.Markup);
Assert.Contains("OpcUa", cut.Markup);
}
[Fact]
public void Search_DimsNonMatches_PreservesShape()
{
SeedRepos(
sites: new[]
{
new Site("Plant-A", "plant-a") { Id = 1 },
new Site("Plant-B", "plant-b") { Id = 2 }
},
connections: new[]
{
new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 },
new DataConnection("RTU-9", "Custom", 2) { Id = 200 }
});
var cut = Render<DataConnectionsPage>();
var search = cut.Find("input[type='text']");
search.Input("Plant-A");
// Both sites remain in the DOM (shape preserved). Plant-B gets the dim style.
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("Plant-B", cut.Markup);
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Plant-B"));
}
[Fact]
public void AddConnectionButton_DisabledUntilSiteSelected()
{
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
var cut = Render<DataConnectionsPage>();
var addBtn = cut.FindAll("button")
.First(b => b.TextContent.Contains("+ Connection"));
Assert.True(addBtn.HasAttribute("disabled"));
// Click the site content (TreeView wires selection on .tv-content).
var siteContent = cut.FindAll(".tv-row")
.First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
.QuerySelector(".tv-content")!;
siteContent.Click();
var addBtnAfter = cut.FindAll("button")
.First(b => b.TextContent.Contains("+ Connection"));
Assert.False(addBtnAfter.HasAttribute("disabled"));
}
[Fact]
public void DataConnectionsRoutes_AreDeclaredOnListPage()
{
// The page moved from Admin to Design; both the canonical
// /design/connections route and the /design/data-connections alias
// must resolve to the list page.
var routes = typeof(DataConnectionsPage).GetCustomAttributes(
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
.Select(a => a.Template)
.ToList();
Assert.Contains("/design/connections", routes);
Assert.Contains("/design/data-connections", routes);
}
}
@@ -0,0 +1,107 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-009. The <c>DebugView</c> stream callbacks
/// (<c>onEvent</c>/<c>onTerminated</c>) run on an Akka/gRPC thread and capture
/// <c>this</c> and <c>_toast</c>. If the user navigates away, an in-flight
/// callback could still call <c>_toast.ShowError(...)</c> /
/// <c>InvokeAsync(StateHasChanged)</c> on a disposed component. The fix adds a
/// <c>_disposed</c> flag checked at the top of every callback, set in
/// <c>Dispose()</c> before the stream is stopped.
/// <para>
/// The Akka-thread timing race itself is not deterministically reproducible in
/// a unit test (<see cref="DebugStreamService"/> is a non-virtual concrete
/// class with no seam to inject and later fire the callbacks). These tests pin
/// the observable parts of the fix: the component exposes a disposal guard, and
/// disposal is clean and idempotent.
/// </para>
/// </summary>
public class DebugViewDisposalTests : BunitContext
{
private void RegisterServices()
{
// DebugView touches localStorage on render; let bUnit answer loosely.
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<ITemplateEngineRepository>();
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
Services.AddSingleton(repo);
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
// An empty throwaway provider — these tests never call StartStreamAsync,
// so the provider is unused. (Services.BuildServiceProvider() would leak
// an undisposed provider.)
var debugStream = new DebugStreamService(
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
NullLogger<DebugStreamService>.Instance);
Services.AddSingleton(debugStream);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
var stubAuth = new StubAuthStateProvider(authState);
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
[Fact]
public void DebugView_HasDisposalGuardField()
{
// The fix introduces a `_disposed` flag that every stream callback
// checks before touching component state.
var field = typeof(DebugViewPage).GetField(
"_disposed", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
Assert.Equal(typeof(bool), field!.FieldType);
}
[Fact]
public void DebugView_Dispose_SetsDisposedFlag_AndIsIdempotent()
{
RegisterServices();
var cut = Render<DebugViewPage>();
var field = typeof(DebugViewPage).GetField(
"_disposed", BindingFlags.Instance | BindingFlags.NonPublic)!;
Assert.False((bool)field.GetValue(cut.Instance)!);
cut.Instance.Dispose();
Assert.True((bool)field.GetValue(cut.Instance)!,
"Dispose() must set the guard so in-flight callbacks no-op.");
// Disposing again must not throw (idempotent).
var ex = Record.Exception(() => cut.Instance.Dispose());
Assert.Null(ex);
}
}
@@ -0,0 +1,156 @@
using System.Collections;
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-021. The <c>DebugView</c> stream callback runs
/// on an Akka/gRPC thread; it used to call <c>UpsertWithCap</c> directly on that
/// thread, mutating the <c>_attributeValues</c>/<c>_alarmStates</c>
/// <see cref="Dictionary{TKey,TValue}"/> while the render thread enumerated the
/// same dictionaries via <c>FilteredAttributeValues</c>. <c>Dictionary</c> is
/// not thread-safe, so the write could throw "Collection was modified" or
/// corrupt the buckets. The fix routes the callback through
/// <c>HandleStreamEvent</c>, which marshals the mutation onto the renderer's
/// dispatcher so every dictionary access happens on one thread.
/// </summary>
public class DebugViewStreamRaceTests : BunitContext
{
private IRenderedComponent<DebugViewPage> RenderPage()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<ITemplateEngineRepository>();
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
Services.AddSingleton(repo);
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
// An empty throwaway provider — these tests drive HandleStreamEvent
// directly and never call StartStreamAsync, so the provider is unused.
// (Services.BuildServiceProvider() would leak an undisposed provider.)
var debugStream = new DebugStreamService(
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
NullLogger<DebugStreamService>.Instance);
Services.AddSingleton(debugStream);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
return Render<DebugViewPage>();
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private static MethodInfo HandleStreamEvent => typeof(DebugViewPage).GetMethod(
"HandleStreamEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static IDictionary AttributeValues(DebugViewPage c) => (IDictionary)
typeof(DebugViewPage).GetField("_attributeValues",
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
private static IEnumerable FilteredAttributeValues(DebugViewPage c) => (IEnumerable)
typeof(DebugViewPage).GetProperty("FilteredAttributeValues",
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
[Fact]
public void HandleStreamEvent_AppliesUpdate_OnceDispatcherRuns()
{
// The fix defers the mutation onto the dispatcher — it must not drop it.
var cut = RenderPage();
var dict = AttributeValues(cut.Instance);
var evt = new AttributeValueChanged(
"Inst-1", "Pump.Speed", "Speed", 42, "Good", DateTimeOffset.UtcNow);
HandleStreamEvent.Invoke(cut.Instance, new object[] { evt });
cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(2));
Assert.True(dict.Contains("Speed"));
}
[Fact]
public async Task HandleStreamEvent_OffThreadEvents_DoNotFaultDispatcherReads()
{
// CentralUI-021 reproduction. Writers fire stream events from background
// threads (the Akka/gRPC callback threads). The reader enumerates
// FilteredAttributeValues *through the renderer's dispatcher* — exactly
// as the real render thread does. Pre-fix the writers mutated the
// Dictionary directly on their own threads, racing the dispatcher-side
// enumeration and intermittently throwing "Collection was modified".
// Post-fix every write is marshalled onto the dispatcher, so writes and
// reads are serialised on one thread and the enumeration never faults.
var cut = RenderPage();
var dict = AttributeValues(cut.Instance);
Exception? failure = null;
using var stop = new CancellationTokenSource();
var writers = Enumerable.Range(0, 4).Select(w => Task.Run(() =>
{
try
{
for (var i = 0; i < 600 && !stop.IsCancellationRequested; i++)
{
var evt = new AttributeValueChanged(
"Inst-1", $"Tag.{w}.{i}", $"Tag-{w}-{i}",
i, "Good", DateTimeOffset.UtcNow);
HandleStreamEvent.Invoke(cut.Instance, new object[] { evt });
}
}
catch (Exception ex) { failure ??= ex; stop.Cancel(); }
})).ToArray();
var reader = Task.Run(async () =>
{
try
{
while (!stop.IsCancellationRequested)
{
await cut.InvokeAsync(() =>
{
foreach (var _ in FilteredAttributeValues(cut.Instance)) { }
});
}
}
catch (Exception ex) { failure ??= ex; stop.Cancel(); }
});
await Task.WhenAll(writers);
stop.Cancel();
await reader.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Null(failure);
// Sanity: events were actually delivered (cap is honoured separately).
cut.WaitForState(() => dict.Count > 0, TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,156 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-006. Component-CentralUI "Real-Time Updates"
/// states deployment status transitions push to the UI immediately via SignalR
/// with no polling. The page previously ran a 10-second <c>Timer</c> that
/// reloaded every deployment record + instance map per tick. The fix removes
/// the timer and subscribes to <see cref="IDeploymentStatusNotifier"/>, which
/// <c>DeploymentService</c> raises on every deployment-record status write;
/// Blazor Server then pushes the re-render over its SignalR circuit.
/// </summary>
public class DeploymentsPushUpdateTests : BunitContext
{
private IDeploymentManagerRepository _deployRepo = null!;
private ITemplateEngineRepository _templateRepo = null!;
private DeploymentStatusNotifier _notifier = null!;
private void RegisterServices()
{
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Inst-1") { Id = 1, SiteId = 1 }
});
_deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<DeploymentRecord>());
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_templateRepo);
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
[Fact]
public void Deployments_DoesNotPoll_HasNoRefreshTimer()
{
// The 10-second polling Timer must be gone — push replaces polling.
var timerField = typeof(DeploymentsPage).GetField(
"_refreshTimer", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Null(timerField);
}
[Fact]
public void Deployments_StatusChange_TriggersReload()
{
RegisterServices();
var cut = Render<DeploymentsPage>();
// Initial load: instances + records each fetched once.
_deployRepo.ClearReceivedCalls();
_templateRepo.ClearReceivedCalls();
// A deployment status write in DeploymentManager raises the notifier;
// the page must reload in response (no polling timer involved).
_notifier.NotifyStatusChanged(
new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success));
cut.WaitForAssertion(() =>
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
}
[Fact]
public void Deployments_Dispose_UnsubscribesFromNotifier()
{
RegisterServices();
var cut = Render<DeploymentsPage>();
cut.Instance.Dispose();
_deployRepo.ClearReceivedCalls();
// After disposal, a status change must NOT touch the disposed component.
_notifier.NotifyStatusChanged(
new DeploymentStatusChange("dep-2", 1, DeploymentStatus.Failed));
_deployRepo.DidNotReceive()
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
}
/// <summary>
/// Regression test for CentralUI-022. The notifier is a process singleton:
/// it can read its subscriber list and begin invoking
/// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an
/// instant before the component is disposed. The handler must no-op against
/// a disposed component rather than letting <c>InvokeAsync</c> throw an
/// unobserved <see cref="ObjectDisposedException"/>.
/// </summary>
[Fact]
public void Deployments_HasDisposalGuardField()
{
var field = typeof(DeploymentsPage).GetField(
"_disposed", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
Assert.Equal(typeof(bool), field!.FieldType);
}
[Fact]
public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload()
{
RegisterServices();
var cut = Render<DeploymentsPage>();
var component = cut.Instance;
component.Dispose();
_deployRepo.ClearReceivedCalls();
// Simulate the race: the notifier captured the handler before the
// Dispose() unsubscribe and invokes it directly against the now-disposed
// component. Pre-fix this dispatched InvokeAsync against a dead circuit
// and threw ObjectDisposedException on a fire-and-forget task.
var handler = typeof(DeploymentsPage).GetMethod(
"OnDeploymentStatusChanged", BindingFlags.Instance | BindingFlags.NonPublic)!;
var ex = Record.Exception(() => handler.Invoke(component,
new object[] { new DeploymentStatusChange("dep-9", 1, DeploymentStatus.Success) }));
Assert.Null(ex);
// The guard short-circuits before any reload is attempted.
_deployRepo.DidNotReceive()
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,100 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceConfigure;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The
/// chip routes operators into the central Audit Log pre-filtered by
/// <c>?instance={Instance.UniqueName}</c>. Instance is UI-only on the filter
/// bar (the repository filter contract has no instance column), so the page
/// uses the UI-text seam — the Audit Log's filter bar pre-populates its
/// Instance free-text input from this query string.
/// </summary>
public class InstanceConfigureAuditDrillinTests : BunitContext
{
private readonly ITemplateEngineRepository _templateRepo =
Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public InstanceConfigureAuditDrillinTests()
{
// Loose JS interop because shared components on the page render
// localStorage / clipboard touches that we don't care about here.
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_templateRepo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>()));
Services.AddSingleton(Substitute.For<IFlatteningPipeline>());
// Auth: a system-wide Deployment user so SiteScope grants everything.
var claims = new[]
{
new Claim("Username", "deployer"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var authProvider = new TestAuthStateProvider(user);
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
Services.AddSingleton(new SiteScopeService(authProvider));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
[Fact]
public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName()
{
var instance = new Instance("Pump-Station-007")
{
Id = 42,
TemplateId = 1,
SiteId = 1,
State = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.InstanceState.NotDeployed,
};
_templateRepo.GetInstanceByIdAsync(42, Arg.Any<CancellationToken>()).Returns(instance);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new Template("Pump") { Id = 1 });
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Site> { new("Plant A", "plant-a") { Id = 1 } });
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<Area>());
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute>());
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection>());
_templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new List<InstanceConnectionBinding>());
_templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new List<InstanceAttributeOverride>());
_templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAlarm>());
_templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new List<InstanceAlarmOverride>());
var cut = Render<InstanceConfigurePage>(p => p.Add(c => c.Id, 42));
cut.WaitForAssertion(() =>
{
var link = cut.Find("a[data-test=\"audit-link\"]");
Assert.Equal("/audit/log?instance=Pump-Station-007", link.GetAttribute("href"));
Assert.Contains("Recent audit activity", link.TextContent);
});
}
}
@@ -0,0 +1,70 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Security;
using ExternalSystemForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.ExternalSystemForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the External Systems edit page.
/// The page-header chip routes operators into the central Audit Log
/// pre-filtered by Target = external-system name. Create mode has nothing
/// to drill into yet, so the link is suppressed.
/// </summary>
public class ExternalSystemFormAuditDrillinTests : BunitContext
{
private readonly IExternalSystemRepository _repo = Substitute.For<IExternalSystemRepository>();
public ExternalSystemFormAuditDrillinTests()
{
Services.AddSingleton(_repo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(JwtTokenService.RoleClaimType, "Design"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName()
{
_repo.GetExternalSystemByIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new ExternalSystemDefinition("ERP-Alpha", "https://erp.example.test", "ApiKey")
{
Id = 7,
});
var cut = Render<ExternalSystemForm>(p => p.Add(c => c.Id, 7));
cut.WaitForAssertion(() =>
{
var link = cut.Find("a[data-test=\"audit-link\"]");
Assert.Equal("/audit/log?target=ERP-Alpha", link.GetAttribute("href"));
Assert.Contains("Recent audit activity", link.TextContent);
});
}
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
// Create mode (Id is null) — there's no real external system to drill into,
// so the link must not render.
var cut = Render<ExternalSystemForm>();
cut.WaitForAssertion(() =>
{
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
});
}
}
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Regression tests for CentralUI-014. Test Run wires <c>External</c>,
/// <c>Database</c>, and <c>Notify</c> to central's real services, so a Test Run
/// has production-equivalent side effects. The finding asked, at minimum, that
/// this blast radius be surfaced to the user. The Test Run panels in
/// <c>SharedScriptForm</c> and <c>TemplateEdit</c> carry a prominent
/// <c>Real I/O</c> badge and an <c>alert-warning</c> block stating the side
/// effects are real and permanent; <c>ApiMethodForm</c> (Inbound API kind) has
/// no real-I/O surface at all and correctly omits the badge. These tests pin
/// that warning so it cannot silently regress.
/// </summary>
public class TestRunWarningTests
{
private static string SrcRoot
{
get
{
// tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/bin/Debug/net10.0 → repo root.
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++)
dir = Directory.GetParent(dir)?.FullName;
return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Design");
}
}
private static string Read(string fileName)
=> File.ReadAllText(Path.Combine(SrcRoot, fileName));
[Theory]
[InlineData("SharedScriptForm.razor")]
[InlineData("TemplateEdit.razor")]
public void TestRunPanel_WithRealIoSurface_ShowsRealIoBadgeAndWarning(string razorFile)
{
var markup = Read(razorFile);
// The "Real I/O" badge on the Test Run panel header.
Assert.Contains("Real I/O", markup);
// The explicit warning that side effects hit real systems and are permanent.
Assert.Contains("alert-warning", markup);
Assert.Contains("fire for real", markup);
Assert.Contains("Side effects are permanent", markup);
}
[Fact]
public void ApiMethodForm_TestRun_HasNoRealIoBadge_BecauseInboundApiHasNoSideEffectSurface()
{
// The Inbound API sandbox host exposes only Parameters / Route (Route
// throws) — there is no External/Database/Notify, so no "Real I/O".
var markup = Read("ApiMethodForm.razor");
Assert.DoesNotContain("Real I/O", markup);
// It still warns that Route calls throw.
Assert.Contains("alert-warning", markup);
}
}
@@ -0,0 +1,231 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Validators;
using OpcUaEndpointEditor = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms.OpcUaEndpointEditor;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Forms;
public class OpcUaEndpointEditorTests : BunitContext
{
[Fact]
public void Renders_All_Four_Section_Labels()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Title, "Primary Endpoint"));
Assert.Contains("Primary Endpoint", cut.Markup);
Assert.Contains("Timing", cut.Markup);
Assert.Contains("Subscription", cut.Markup);
Assert.Contains("Heartbeat", cut.Markup);
}
[Fact]
public void Binding_MutatesPassedConfigInstance()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.Find("input[type='text']").Change("opc.tcp://new-host:4840");
Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl);
}
[Fact]
public void EnableHeartbeat_CreatesSubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.Heartbeat);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Heartbeat")).Click();
Assert.NotNull(config.Heartbeat);
}
[Fact]
public void RemoveHeartbeat_NullsSubObject()
{
var config = new OpcUaEndpointConfig
{
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 30 }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Heartbeat")).Click();
Assert.Null(config.Heartbeat);
}
[Fact]
public void Errors_Parameter_RendersPerFieldRedText()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "" };
var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary.");
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Errors, errors));
Assert.Contains("Endpoint URL is required.", cut.Markup);
Assert.Contains("text-danger", cut.Markup);
}
[Fact]
public void IsLegacy_True_RendersWarningBanner()
{
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, new OpcUaEndpointConfig())
.Add(c => c.IsLegacy, true));
Assert.Contains("alert-warning", cut.Markup);
Assert.Contains("migrated from a legacy format", cut.Markup);
}
// ── Layer E: new editor sections ──
[Fact]
public void Renders_Authentication_Section_Label()
{
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, new OpcUaEndpointConfig()));
Assert.Contains("Authentication", cut.Markup);
}
[Fact]
public void EnableAuthentication_CreatesUserIdentitySubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.UserIdentity);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Authentication")).Click();
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
}
[Fact]
public void RemoveAuthentication_NullsUserIdentity()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "alice",
Password = "secret"
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Authentication")).Click();
Assert.Null(config.UserIdentity);
}
[Fact]
public void UsernamePassword_RendersUsernameAndPasswordInputs()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
// <label>Username</label> only renders for the UsernamePassword branch
Assert.Contains(">Username<", cut.Markup);
Assert.Contains(">Password<", cut.Markup);
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
[Fact]
public void X509Certificate_RendersCertificateFields()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.X509Certificate
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Contains(">Certificate path<", cut.Markup);
Assert.Contains(">Certificate password<", cut.Markup);
Assert.DoesNotContain(">Username<", cut.Markup);
}
[Fact]
public void AnonymousTokenType_ShowsNoExtraFields()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.DoesNotContain(">Username<", cut.Markup);
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
[Fact]
public void EnableDeadband_CreatesDeadbandSubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.Deadband);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Deadband")).Click();
Assert.NotNull(config.Deadband);
}
[Fact]
public void RemoveDeadband_NullsDeadband()
{
var config = new OpcUaEndpointConfig
{
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Deadband")).Click();
Assert.Null(config.Deadband);
}
[Fact]
public void AdvancedSubscription_Section_Renders()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Contains("Discard oldest", cut.Markup);
Assert.Contains("Subscription display name", cut.Markup);
Assert.Contains("Subscription priority", cut.Markup);
Assert.Contains("Timestamps to return", cut.Markup);
}
[Fact]
public void UserIdentityError_RendersPerFieldUnderUsername()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = ""
}
};
var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary.");
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Errors, errors));
Assert.Contains("Username is required", cut.Markup);
}
}
@@ -0,0 +1,158 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Security;
using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout;
/// <summary>
/// bUnit rendering tests for the sidebar <see cref="NavMenu"/>. They verify the
/// collapsible section behaviour (sections collapsed by default, a toggle
/// reveals a section's items and persists state to a cookie) and that the
/// Notifications section's items are gated per-policy. The
/// <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which
/// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> ("Role"),
/// so the test principal carries claims of that exact type.
/// </summary>
public class NavMenuTests : BunitContext
{
public NavMenuTests()
{
// NavMenu reads the nav-collapse cookie via the navState.get JS interop
// call in OnAfterRenderAsync and writes it via navState.set on toggle.
// Loose mode lets navState.get no-op (returns null) so the sidebar
// renders collapsed, and still records navState.set invocations.
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>
/// Renders <see cref="NavMenu"/> under a principal holding the given roles.
/// <see cref="NavMenu"/>'s top-level <c>AuthorizeView</c> requires the
/// cascading <see cref="AuthenticationState"/>, so it is rendered inside a
/// <see cref="CascadingAuthenticationState"/>; the real policies are
/// registered so the per-item <c>AuthorizeView Policy=...</c> blocks are
/// genuinely evaluated.
/// </summary>
private IRenderedComponent<NavMenu> RenderWithRoles(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that
// throws when AuthorizeView evaluates a policy. Force the real service
// so the per-item policy gating is genuinely exercised.
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<NavMenu>(0);
builder.CloseComponent();
})));
return host.FindComponent<NavMenu>();
}
/// <summary>
/// Clicks the collapsible section header whose title matches, expanding it.
/// </summary>
private static void ExpandSection(IRenderedComponent<NavMenu> cut, string title)
{
var toggle = cut.FindAll("button.nav-section-toggle")
.Single(b => b.TextContent.Contains(title, StringComparison.Ordinal));
toggle.Click();
}
[Fact]
public void Sections_AreCollapsedByDefault()
{
var cut = RenderWithRoles("Admin", "Design", "Deployment");
cut.WaitForAssertion(() =>
{
// Section headers render unconditionally...
Assert.Contains(">Notifications<", cut.Markup);
Assert.Contains(">Deployment<", cut.Markup);
// ...but their items stay out of the DOM until the section opens.
Assert.DoesNotContain("/notifications/smtp", cut.Markup);
Assert.DoesNotContain("/deployment/topology", cut.Markup);
});
}
[Fact]
public void TogglingSection_RevealsItsItems()
{
var cut = RenderWithRoles("Deployment");
Assert.DoesNotContain("/deployment/topology", cut.Markup);
ExpandSection(cut, "Deployment");
Assert.Contains("/deployment/topology", cut.Markup);
Assert.Contains("/deployment/deployments", cut.Markup);
Assert.Contains("/deployment/debug-view", cut.Markup);
}
[Fact]
public void TogglingSection_PersistsStateToCookie()
{
var cut = RenderWithRoles("Deployment");
ExpandSection(cut, "Deployment");
// Expanding wrote the cookie through the navState.set JS interop call.
var invocation = JSInterop.Invocations.Last(i => i.Identifier == "navState.set");
Assert.Equal("deployment", invocation.Arguments[0]);
}
[Fact]
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
{
var cut = RenderWithRoles("Admin", "Design", "Deployment");
ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() =>
{
Assert.Contains("Notifications", cut.Markup);
Assert.Contains("/notifications/smtp", cut.Markup);
Assert.Contains("/notifications/lists", cut.Markup);
Assert.Contains("/notifications/report", cut.Markup);
Assert.Contains("/notifications/kpis", cut.Markup);
});
}
[Fact]
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
{
var cut = RenderWithRoles("Admin");
ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() =>
{
Assert.Contains("/notifications/smtp", cut.Markup);
Assert.DoesNotContain("/notifications/report", cut.Markup);
Assert.DoesNotContain("/notifications/lists", cut.Markup);
Assert.DoesNotContain("/notifications/kpis", cut.Markup);
});
}
[Fact]
public void OldRoutes_AreNoLongerLinked()
{
var cut = RenderWithRoles("Admin", "Design", "Deployment");
cut.WaitForAssertion(() =>
{
Assert.DoesNotContain("/admin/smtp", cut.Markup);
Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup);
});
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Regression tests for CentralUI-008. <c>&lt;input type="datetime-local"&gt;</c>
/// yields the value the user typed in their <i>browser-local</i> time zone. The
/// audit-log filter converted it with <c>new DateTimeOffset(value, TimeSpan.Zero)</c>
/// — relabelling the local wall-clock value as UTC, shifting the query window by
/// the user's offset. <see cref="BrowserTime.LocalInputToUtc"/> performs the
/// correct conversion: it applies the browser offset from <c>getTimezoneOffset()</c>.
/// </summary>
public class BrowserTimeTests
{
[Fact]
public void LocalInputToUtc_Null_ReturnsNull()
{
Assert.Null(BrowserTime.LocalInputToUtc(null, 0));
}
[Fact]
public void LocalInputToUtc_UtcBrowser_LeavesTimeUnchanged()
{
// getTimezoneOffset() == 0 for a UTC browser.
var local = new DateTime(2026, 5, 16, 9, 30, 0);
var utc = BrowserTime.LocalInputToUtc(local, 0);
Assert.Equal(new DateTimeOffset(2026, 5, 16, 9, 30, 0, TimeSpan.Zero), utc);
}
[Fact]
public void LocalInputToUtc_PositiveUtcOffsetBrowser_SubtractsOffset()
{
// A browser at UTC+2 reports getTimezoneOffset() == -120.
// The user typing 09:30 local means 07:30 UTC.
var local = new DateTime(2026, 5, 16, 9, 30, 0);
var utc = BrowserTime.LocalInputToUtc(local, -120);
Assert.Equal(new DateTimeOffset(2026, 5, 16, 7, 30, 0, TimeSpan.Zero), utc);
}
[Fact]
public void LocalInputToUtc_NegativeUtcOffsetBrowser_AddsOffset()
{
// A browser at UTC-5 (US Eastern, standard time) reports getTimezoneOffset() == 300.
// The user typing 09:30 local means 14:30 UTC.
var local = new DateTime(2026, 5, 16, 9, 30, 0);
var utc = BrowserTime.LocalInputToUtc(local, 300);
Assert.Equal(new DateTimeOffset(2026, 5, 16, 14, 30, 0, TimeSpan.Zero), utc);
}
[Fact]
public void LocalInputToUtc_NonUtcBrowser_DoesNotEqualNaiveRelabelling()
{
// The pre-fix bug: naive new DateTimeOffset(value, TimeSpan.Zero).
var local = new DateTime(2026, 5, 16, 9, 30, 0);
var naive = new DateTimeOffset(local, TimeSpan.Zero);
var correct = BrowserTime.LocalInputToUtc(local, 300);
Assert.NotEqual(naive, correct);
}
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Regression tests for CentralUI-007. The design doc classifies the Site Event
/// Log Viewer and Parked Message Management as <b>Deployment Role</b>, but both
/// pages were annotated only <c>[Authorize]</c> (any authenticated user) — a
/// non-Deployment user who followed the nav link could query event logs and
/// retry/discard parked messages. The Health Dashboard is intentionally
/// all-roles per the design.
/// </summary>
public class MonitoringAuthorizationTests
{
private static AuthorizeAttribute? AuthorizeOf<TPage>()
=> typeof(TPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
[Fact]
public void EventLogsPage_RequiresDeploymentPolicy()
{
var attr = AuthorizeOf<EventLogs>();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void ParkedMessagesPage_RequiresDeploymentPolicy()
{
var attr = AuthorizeOf<ParkedMessages>();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void HealthDashboard_IsIntentionallyAllAuthenticatedRoles()
{
// Health Dashboard stays all-roles (no policy) per the design doc.
var attr = AuthorizeOf<Health>();
Assert.NotNull(attr);
Assert.Null(attr!.Policy);
}
}
@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.WebUtilities;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
/// at; the same conversion is round-tripped on the server side by
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
/// These tests pin the no-filter base path + the round-trip back through
/// <see cref="QueryHelpers.ParseQuery"/> so the link contract stays stable.
/// </summary>
public class AuditLogPageExportUrlTests
{
[Fact]
public void BuildExportUrl_NullFilter_ReturnsBasePath()
{
var url = AuditLogPage.BuildExportUrl(null);
Assert.Equal("/api/centralui/audit/export", url);
}
[Fact]
public void BuildExportUrl_EmptyFilter_ReturnsBasePath()
{
// Defensive: a filter where every column is null should still render
// as the bare path — no trailing "?" so the URL stays clean.
var url = AuditLogPage.BuildExportUrl(new AuditLogQueryFilter());
Assert.Equal("/api/centralui/audit/export", url);
}
[Fact]
public void BuildExportUrl_AllFiltersSet_RoundTrips()
{
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound },
Kinds: new[] { AuditKind.ApiCall },
Statuses: new[] { AuditStatus.Failed },
SourceSiteIds: new[] { "plant-a" },
Target: "PaymentApi",
Actor: "apikey-1",
CorrelationId: corr,
FromUtc: new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc),
ToUtc: new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc));
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Equal("ApiOutbound", query["channel"]);
Assert.Equal("ApiCall", query["kind"]);
Assert.Equal("Failed", query["status"]);
Assert.Equal("plant-a", query["site"]);
Assert.Equal("PaymentApi", query["target"]);
Assert.Equal("apikey-1", query["actor"]);
Assert.Equal(corr.ToString(), query["correlationId"]);
Assert.Equal("2026-05-20T00:00:00.0000000Z", query["from"]);
Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]);
}
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal("Notification", query["channel"]);
}
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
var filter = new AuditLogQueryFilter(ExecutionId: exec);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal(exec.ToString(), query["executionId"]);
}
[Fact]
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
{
var parent = Guid.Parse("34343434-5656-7878-9090-121212121212");
var filter = new AuditLogQueryFilter(ParentExecutionId: parent);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
}
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
// Task 9: each multi-value dimension emits one repeated query-string key
// per selected value so the export endpoint's ParseFilter sees them all.
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
SourceSiteIds: new[] { "plant-a", "plant-b" });
var url = AuditLogPage.BuildExportUrl(filter);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray());
Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray());
Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray());
}
}
@@ -0,0 +1,332 @@
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Bunit;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
///
/// <para>
/// Bundle G introduces two new policies:
/// <list type="bullet">
/// <item><c>OperationalAudit</c> — read access to the Audit Log page +
/// Configuration Audit Log page + nav group.</item>
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
/// the streaming export endpoint.</item>
/// </list>
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth)
/// the <c>Admin</c> role — admins see everything by convention in this
/// codebase. The tests pin both the page-level + endpoint-level enforcement,
/// and the Export-button visibility split.
/// </para>
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the permission-gating tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private void WireUpPageDependencies()
{
// The page hosts AuditFilterBar + AuditResultsGrid which depend on
// ISiteRepository and IAuditLogQueryService — provide stand-ins so
// a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
}
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
WireUpPageDependencies();
// Page-level [Authorize(Policy=...)] is enforced by the router in a
// live app. bUnit renders the component directly, so we wrap the
// page in a CascadingAuthenticationState so the in-page
// AuthorizeView for the Export button can read the principal.
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<AuditLogPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<AuditLogPage>();
}
// ─────────────────────────────────────────────────────────────────────
// Test 1: WithoutOperationalAudit_PageReturns403_OrHidden
// ─────────────────────────────────────────────────────────────────────
//
// Page-level enforcement is the [Authorize(Policy = "OperationalAudit")]
// attribute on the .razor page. We can't easily smoke-test routing here,
// so we verify the attribute is present + the policy denies a principal
// that holds none of the permitting roles.
[Fact]
public async Task WithoutOperationalAudit_PolicyDenies()
{
// A Design-only user (no Audit, no Admin) must NOT satisfy the
// OperationalAudit policy.
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
var principal = BuildPrincipal("Design");
var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.OperationalAudit);
Assert.False(result.Succeeded);
}
[Fact]
public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
// Sanity-pin the attribute so the page-level gate can't regress to
// [Authorize] (any-authenticated) by accident.
var attributes = typeof(AuditLogPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
[Fact]
public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
// ConfigurationAuditLog mirrors the gate — both Audit-group pages
// share the OperationalAudit permission so the nav-group policy
// remains coherent with the per-page gates.
var configType = typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ConfigurationAuditLog);
var attributes = configType
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
// ─────────────────────────────────────────────────────────────────────
// Test 2 + 3: Export button visibility split.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
{
// The "Audit" role grants OperationalAudit + AuditExport in the
// default mapping, so we test the split by handing the user ONLY
// an extra-narrow role that we map ONLY to OperationalAudit: a
// fresh "AuditReadOnly" role (see AuthorizationPolicies).
var cut = RenderAuditLogPage("AuditReadOnly");
cut.WaitForAssertion(() =>
{
// The page rendered (heading + container present) but the
// Export-CSV anchor is gone because AuditExport is denied.
Assert.Contains("Audit Log", cut.Markup);
Assert.DoesNotContain("Export CSV", cut.Markup);
});
}
[Fact]
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
{
var cut = RenderAuditLogPage("Audit");
cut.WaitForAssertion(() =>
{
Assert.Contains("Audit Log", cut.Markup);
Assert.Contains("Export CSV", cut.Markup);
});
}
[Fact]
public void AdminUser_SeesPage_AndExportButton()
{
// Admin holds every permission by convention — both policies must
// succeed for a plain Admin user.
var cut = RenderAuditLogPage("Admin");
cut.WaitForAssertion(() =>
{
Assert.Contains("Audit Log", cut.Markup);
Assert.Contains("Export CSV", cut.Markup);
});
}
// ─────────────────────────────────────────────────────────────────────
// Test 4 + 5: Endpoint-level enforcement.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
{
// A user holding only Design must NOT be able to call the export
// endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
{
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_AdminAlone_Returns200()
{
// Admin alone (no Audit role) must still pass — defence in depth.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_AuditReadOnly_Returns403()
{
// AuditReadOnly grants OperationalAudit but NOT AuditExport, so the
// endpoint must refuse — the page is readable but the bulk export
// path is gated separately.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// Helper: tiny in-process host with the real AuthorizationPolicies.
// ─────────────────────────────────────────────────────────────────────
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildEndpointHostAsync(
string[] roles)
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddAuthentication(FakeAuthHandler.SchemeName)
.AddScheme<FakeAuthHandlerOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, opts => opts.Roles = roles);
// Real policies — the whole point of these tests is to
// exercise the production AddScadaBridgeAuthorization wiring.
services.AddScadaBridgeAuthorization();
services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapAuditExportEndpoints();
});
});
});
var host = await hostBuilder.StartAsync();
var client = host.GetTestClient();
return (client, repo, host);
}
/// <summary>
/// Test-only authentication handler that signs every request in with
/// the configured set of roles.
/// </summary>
private sealed class FakeAuthHandler : AuthenticationHandler<FakeAuthHandlerOptions>
{
public const string SchemeName = "FakeAuth";
public FakeAuthHandler(
IOptionsMonitor<FakeAuthHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
foreach (var role in Options.Roles)
{
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
{
public string[] Roles { get; set; } = Array.Empty<string>();
}
}
@@ -0,0 +1,365 @@
using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit
/// nav group that hosts both it and the renamed Configuration Audit Log
/// (#23 M7 Bundle A).
///
/// These are render-only smoke tests — the filter bar and results grid
/// are intentional placeholders that Bundle B fills in. The tests pin
/// the page route, page heading, nav group label, and the two child
/// links so later bundles cannot regress the scaffolding.
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the page scaffold smoke tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
{
return RenderAuditLogPageWithQuery(query: null, roles: roles);
}
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
private IRenderedComponent<AuditLogPage> RenderAuditLogPageWithQuery(string? query, params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The page now hosts AuditFilterBar + AuditResultsGrid which depend on
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
// Provide stand-ins so the scaffold smoke tests still render the page.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
Services.AddSingleton(_queryService);
if (!string.IsNullOrEmpty(query))
{
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"/audit/log?{query}");
}
// Bundle G (#23 M7-T15): the page now hosts an in-component
// AuthorizeView around the Export-CSV button, so the page MUST
// render inside a CascadingAuthenticationState. The router supplies
// this in production; bUnit hosts the page directly so we wrap it
// here.
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<AuditLogPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<AuditLogPage>();
}
private IRenderedComponent<NavMenu> RenderNavMenu(params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<NavMenu>(0);
builder.CloseComponent();
})));
return host.FindComponent<NavMenu>();
}
/// <summary>
/// Clicks the collapsible section header whose title matches, expanding it.
/// Nav sections are collapsed by default, so a section's items are only in
/// the DOM once expanded.
/// </summary>
private static void ExpandNavSection(IRenderedComponent<NavMenu> cut, string title)
{
var toggle = cut.FindAll("button.nav-section-toggle")
.Single(b => b.TextContent.Contains(title, StringComparison.Ordinal));
toggle.Click();
}
[Fact]
public void AuditLogPage_Renders_PageHeading()
{
var cut = RenderAuditLogPage("Admin");
cut.WaitForAssertion(() =>
{
// The H1 is the only positive scaffold assertion — the filter
// bar and grid are still placeholders the Bundle B work fills.
Assert.Contains("<h1", cut.Markup);
Assert.Contains("Audit Log", cut.Markup);
});
}
[Fact]
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
{
var cut = RenderNavMenu("Admin", "Design", "Deployment");
ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() =>
{
Assert.Contains(">Audit<", cut.Markup);
Assert.Contains("/audit/log", cut.Markup);
});
}
[Fact]
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
{
var cut = RenderNavMenu("Admin", "Design", "Deployment");
ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() =>
{
// Both audit pages must appear after the Audit section header
// in the rendered nav. We check both links + that the header
// comes before either link in the markup, so they are in the
// Audit group rather than orphaned under Monitoring.
Assert.Contains("/audit/configuration", cut.Markup);
Assert.Contains("/audit/log", cut.Markup);
var headerIdx = cut.Markup.IndexOf(">Audit<", StringComparison.Ordinal);
var configIdx = cut.Markup.IndexOf("/audit/configuration", StringComparison.Ordinal);
var logIdx = cut.Markup.IndexOf("/audit/log", StringComparison.Ordinal);
Assert.True(headerIdx >= 0 && headerIdx < configIdx,
"Audit section header must precede the Configuration Audit Log link.");
Assert.True(headerIdx >= 0 && headerIdx < logIdx,
"Audit section header must precede the Audit Log link.");
});
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D — query-string drill-in parsing (#23 M7-T10..T12)
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads()
{
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin");
cut.WaitForAssertion(() =>
{
// Auto-load fires because correlationId is a real filter dimension.
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.CorrelationId == corr),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
// set, and auto-loads the grid.
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
// An unparseable executionId leaves ExecutionId null. With no other filter
// params present the page renders but does NOT call the query service.
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View parent execution" drill-in (and operator-crafted URLs) land on
// /audit/log?parentExecutionId={id}. The page parses the Guid, builds an
// AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid.
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == parentExecutionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin");
// An unparseable parentExecutionId leaves ParentExecutionId null. With no
// other filter params present the page renders but does NOT call the query
// service.
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Target == "ExternalSystem-Alpha"),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithSiteParam_AppliesSiteFilter()
{
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithStatusParam_AppliesStatusFilter()
{
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
// in with ?status=Failed. The page parses the enum (case-insensitive),
// builds an AuditLogQueryFilter with Status set, and auto-loads.
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin");
// An unparseable status value leaves Status null. With no other filter
// params present the page renders but does NOT call the query service
// (matching the existing "no params" contract).
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPage("Admin");
// The grid is in "no filter" state — the page heading renders, but the
// query service must NOT be hit because nothing told us to load.
cut.WaitForAssertion(() =>
{
Assert.Contains("Audit Log", cut.Markup);
});
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,325 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Export;
using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportExport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
///
/// <para>
/// Covers the four contract points the design plan calls out:
/// </para>
/// <list type="number">
/// <item>Step 1 renders the template tree plus every flat artifact group.</item>
/// <item>Step 2 surfaces the dependency-resolved closure (seed vs auto-included).</item>
/// <item>Step 4 invokes <see cref="IBundleExporter.ExportAsync"/> with the user's
/// selected ids and authenticated identity.</item>
/// <item>The page-level <c>RequireDesign</c> policy denies a user lacking the
/// Design role (router enforcement; the component code-behind never sees
/// the request).</item>
/// </list>
///
/// <para>
/// JS interop is set to loose mode so the TreeView's sessionStorage round-trip
/// and the transport-bundle download interop don't need stubs per test. The
/// <c>scadabridgeTransport.downloadBundle</c> call returns void — loose mode is
/// the lighter wiring than re-stubbing it in every export-path test.
/// </para>
/// </summary>
public class TransportExportPageTests : BunitContext
{
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
public TransportExportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
// Default empty repos so OnInitializedAsync doesn't throw — individual
// tests override the bits they care about.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
_templateRepo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript>()));
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(new List<ExternalSystemDefinition>()));
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(new List<DatabaseConnectionDefinition>()));
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey>()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
Services.AddSingleton(_templateRepo);
Services.AddSingleton(_externalRepo);
Services.AddSingleton(_notificationRepo);
Services.AddSingleton(_inboundApiRepo);
Services.AddSingleton(_exporter);
// DependencyResolver is sealed but its only dependencies are the four
// repositories above — registering the concrete type is enough.
Services.AddSingleton<DependencyResolver>();
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
SourceEnvironment = "test-cluster",
}));
var principal = BuildPrincipal("alice", "Design");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
}
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the template tree and every flat artifact group.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
{
// A single template + a couple of artifacts so the lists aren't empty.
var template = new Template("Pump") { Id = 1 };
var script = new SharedScript("Helpers", "// noop") { Id = 10 };
var externalSystem = new ExternalSystemDefinition("ERP", "https://erp.example.com", "ApiKey")
{
Id = 20,
};
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
var notifList = new NotificationList("Ops") { Id = 40 };
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
var apiKey = new ApiKey("ext-system", "key-hash") { Id = 60 };
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript> { script }));
_externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(
new List<ExternalSystemDefinition> { externalSystem }));
_externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(
new List<DatabaseConnectionDefinition> { db }));
_notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey> { apiKey }));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump"));
// All six flat groups (plus templates) are present.
foreach (var groupId in new[]
{
"group-templates",
"group-shared-scripts",
"group-external-systems",
"group-db-connections",
"group-notification-lists",
"group-smtp-configs",
"group-api-keys",
"group-api-methods",
})
{
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
}
// Sanity: each artifact shows its label.
Assert.Contains("Helpers", cut.Markup);
Assert.Contains("ERP", cut.Markup);
Assert.Contains("Hist", cut.Markup);
Assert.Contains("Ops", cut.Markup);
Assert.Contains("smtp.example.com", cut.Markup);
Assert.Contains("ext-system", cut.Markup);
Assert.Contains("CreateOrder", cut.Markup);
// Next button is disabled while no selection exists.
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
Assert.True(next.HasAttribute("disabled"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
// in because a seed template composes them.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
{
// Seed template "Pump" composes "Motor". The user selects Pump only;
// the resolver pulls Motor in transitively.
var pump = new Template("Pump") { Id = 1 };
pump.Compositions.Add(new TemplateComposition("MotorSlot")
{
Id = 100,
ComposedTemplateId = 2,
});
var motor = new Template("Motor") { Id = 2 };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { pump, motor }));
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(pump));
_templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(motor));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump"));
// The template-tree renders a checkbox per node — tick the one whose
// sibling label is "Pump". (TemplateFolderTree uses .tv-checkbox.)
var pumpRow = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Pump"));
var checkbox = pumpRow.QuerySelector("input.tv-checkbox");
Assert.NotNull(checkbox);
checkbox!.Change(true);
// Click "Next" to advance to Step 2; the resolver call is awaited
// inside GoToReviewAsync — bUnit's WaitForState handles the re-render.
var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
await next.ClickAsync(new());
cut.WaitForAssertion(() =>
{
// Step 2 shows the seed/auto split — Motor lands under "Auto-included".
var autoGroup = cut.Find("[data-testid='auto-group']");
Assert.Contains("Motor", autoGroup.TextContent);
});
var seedGroup = cut.Find("[data-testid='seed-group']");
Assert.Contains("Pump", seedGroup.TextContent);
}
// ─────────────────────────────────────────────────────────────────────
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
// is invoked with the user-selected ids and the authenticated identity.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
{
var template = new Template("Pump") { Id = 1 };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(template));
// Exporter returns a tiny in-memory bundle stream.
_exporter
.ExportAsync(
Arg.Any<ExportSelection>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump"));
// Tick Pump.
var pumpCheckbox = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Pump"))
.QuerySelector("input.tv-checkbox");
Assert.NotNull(pumpCheckbox);
pumpCheckbox!.Change(true);
// Advance Step 1 → 2.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
// Advance Step 2 → 3.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));
// Fill matching passphrases. The inputs are wired with @bind:event="oninput",
// so use Input() rather than Change() to fire the right event.
var passphraseInput = cut.Find("#passphrase");
passphraseInput.Input("hunter2hunter2");
var confirmInput = cut.Find("#passphrase-confirm");
confirmInput.Input("hunter2hunter2");
// Click "Export" — the only enabled button labeled "Export" at this step.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
// Step 4 renders the download summary once ExportAsync resolves.
cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));
await _exporter.Received(1).ExportAsync(
Arg.Is<ExportSelection>(s =>
s.TemplateIds.Contains(1)
&& s.IncludeDependencies),
"alice",
"test-cluster",
"hunter2hunter2",
Arg.Any<CancellationToken>());
}
// ─────────────────────────────────────────────────────────────────────
// Test 4: A user without the Design role fails the RequireDesign policy.
// The router enforces [Authorize(Policy=...)] at request time — bUnit
// doesn't model routing, so we verify the policy itself denies the
// principal (the same gate the router consults).
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Design_role()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
// Audit-only user — has a role but it isn't Design.
var principal = BuildPrincipal("bob", "Audit");
var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.RequireDesign);
Assert.False(result.Succeeded);
}
// ─────────────────────────────────────────────────────────────────────
// Static helpers — exercised directly so the file-naming + secret-count
// contract is unit-pinned independently of the rendering surface.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
{
var fixedTime = new DateTimeOffset(2026, 5, 24, 13, 45, 22, TimeSpan.Zero);
var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
}
}
@@ -0,0 +1,391 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Transport;
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
///
/// <para>
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
/// Selecting a file via <c>InputFile</c> is hard to drive cleanly from bUnit
/// (JS interop + DotNetStreamReference), so the state-machine tests reach into
/// the page instance via <c>cut.Instance</c> and the <c>InternalsVisibleTo</c>
/// declaration on <c>ZB.MOM.WW.ScadaBridge.CentralUI.csproj</c>. The <c>BundleImporter</c>
/// mock controls every load/preview/apply contract so each step's behaviour can
/// be exercised in isolation. The full happy-path round-trip is covered by the
/// integration tests in <c>ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests</c>.
/// </para>
/// </summary>
public class TransportImportPageTests : BunitContext
{
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
// Provide a SQLite in-memory ScadaBridgeDbContext so the page's
// DbContext.SaveChangesAsync() calls in the audit path succeed.
var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
.Options;
var dbContext = new ScadaBridgeDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
var principal = BuildPrincipal("alice", "Admin");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
}
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private static BundleSession BuildEncryptedSession(string sourceEnv = "prod-cluster") =>
new()
{
SessionId = Guid.NewGuid(),
Manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: sourceEnv,
ExportedBy: "bob",
ScadaBridgeVersion: "1.0.0",
ContentHash: "sha256:0000",
Encryption: new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>()),
DecryptedContent = Array.Empty<byte>(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
};
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the InputFile upload control.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void Renders_step1_upload_input()
{
var cut = Render<TransportImportPage>();
// Bootstrap classes are applied by InputFile via the CSS class attribute.
Assert.NotNull(cut.Find("input[type='file']"));
// The Bootstrap step indicator should highlight Step 1.
Assert.Contains("Upload", cut.Markup);
}
// ─────────────────────────────────────────────────────────────────────
// Test 2: Wrong passphrase increments the failure counter without
// advancing past Step 2.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Decryption_failure_increments_attempt_counter()
{
// Set up the importer to throw CryptographicException for wrong passphrases.
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
// Seed the wizard at the passphrase step with cached bytes.
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
SetField(cut.Instance, "_passphrase", "wrong-pass");
});
// Drive a passphrase submission.
await cut.InvokeAsync(async () =>
{
await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
});
Assert.Equal(1, GetField<int>(cut.Instance, "_failedUnlockAttempts"));
Assert.Equal(
TransportImportPage.ImportWizardStep.Passphrase,
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
// to Step 1 with an explanatory error.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Three_failed_unlocks_force_reupload()
{
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new CryptographicException("authentication tag mismatch"));
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
});
for (var i = 0; i < 3; i++)
{
await cut.InvokeAsync(async () =>
{
SetField(cut.Instance, "_passphrase", $"wrong-{i}");
await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
});
}
Assert.Equal(
TransportImportPage.ImportWizardStep.Upload,
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
var errorMessage = GetField<string?>(cut.Instance, "_errorMessage");
Assert.NotNull(errorMessage);
Assert.Contains("Too many failed unlock attempts", errorMessage);
}
// ─────────────────────────────────────────────────────────────────────
// Test 4: Confirm step requires an exact match (case-sensitive) on the
// source environment name before Apply is enabled.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Confirm_step_requires_exact_environment_name_match()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
_importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>())
.Returns(new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
}));
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", null, 1, ConflictKind.New, null, null),
}));
SetField(cut.Instance, "_resolutions", new Dictionary<(string EntityType, string Name), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
});
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
});
// Wrong text → Apply button is disabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "wrong"));
cut.Render();
var applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.True(applyBtn.HasAttribute("disabled"));
// Case mismatch → still disabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "PROD-CLUSTER"));
cut.Render();
applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.True(applyBtn.HasAttribute("disabled"));
// Exact match → enabled.
await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
cut.Render();
applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
Assert.False(applyBtn.HasAttribute("disabled"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 5: ApplyAsync is invoked with the chosen resolutions and the
// authenticated user identity.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
{
var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
var resolutions = new Dictionary<(string EntityType, string Name), ImportResolution>
{
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Overwrite, null),
};
var expectedResult = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0,
Overwritten: 1,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: Guid.NewGuid().ToString());
_importer.ApplyAsync(
session.SessionId,
Arg.Any<IReadOnlyList<ImportResolution>>(),
"alice",
Arg.Any<CancellationToken>())
.Returns(expectedResult);
var cut = Render<TransportImportPage>();
await cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_session", session);
SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
{
new("Template", "Pump", 1, 2, ConflictKind.Modified, null, null),
}));
SetField(cut.Instance, "_resolutions", resolutions);
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
});
await cut.InvokeAsync(async () =>
{
await InvokeAsyncMethod(cut.Instance, "ApplyAsync");
});
await _importer.Received(1).ApplyAsync(
session.SessionId,
Arg.Is<IReadOnlyList<ImportResolution>>(rs =>
rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
&& r.Action == ResolutionAction.Overwrite)),
"alice",
Arg.Any<CancellationToken>());
Assert.Equal(
TransportImportPage.ImportWizardStep.Result,
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
Assert.Equal(expectedResult, GetField<ImportResult?>(cut.Instance, "_result"));
}
// ─────────────────────────────────────────────────────────────────────
// Test 6: A user without the Admin role fails the RequireAdmin policy.
// The router enforces [Authorize(Policy=...)] at request time — bUnit
// doesn't model routing, so we verify the policy itself denies the
// principal.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Admin_role()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
// Design-only user — has a role but it isn't Admin.
var principal = BuildPrincipal("bob", "Design");
var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.RequireAdmin);
Assert.False(result.Succeeded);
}
// ─────────────────────────────────────────────────────────────────────
// Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
// expected default ResolutionAction.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void BuildDefaultResolutions_maps_kinds_to_actions()
{
var preview = new ImportPreview(Guid.NewGuid(), new List<ImportPreviewItem>
{
new("Template", "A", 1, 1, ConflictKind.Identical, null, null),
new("Template", "B", null, 1, ConflictKind.New, null, null),
new("Template", "C", 1, 2, ConflictKind.Modified, null, null),
new("Reference", "D", null, null, ConflictKind.Blocker, null, "missing dep"),
});
var map = TransportImportPage.BuildDefaultResolutions(preview);
Assert.Equal(ResolutionAction.Skip, map[("Template", "A")].Action);
Assert.Equal(ResolutionAction.Add, map[("Template", "B")].Action);
Assert.Equal(ResolutionAction.Overwrite, map[("Template", "C")].Action);
Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
}
// ─────────────────────────────────────────────────────────────────────
// Reflection helpers — the wizard's per-instance state is private (the
// razor partial pattern). We poke at it via reflection rather than
// widening the surface of the production class with test-only accessors.
// ─────────────────────────────────────────────────────────────────────
private static void SetField(object obj, string name, object? value)
{
var field = obj.GetType().GetField(
name,
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Public)
?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
field.SetValue(obj, value);
}
private static T GetField<T>(object obj, string name)
{
var field = obj.GetType().GetField(
name,
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Public)
?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
return (T)field.GetValue(obj)!;
}
private static async Task InvokeAsyncMethod(object obj, string name)
{
var method = obj.GetType().GetMethod(
name,
System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Public)
?? throw new InvalidOperationException($"Method '{name}' not found on {obj.GetType()}.");
var task = (Task)method.Invoke(obj, Array.Empty<object?>())!;
await task;
}
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and
/// surfaced an ArgumentException ("passphrase required"). CentralUI-031:
/// the wizard now stages the upload to a temp file and only retains the
/// path on the component, so the test helper writes the bytes to a per-
/// test temp file and sets the path field instead of the byte[] field.
/// </summary>
private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes)
{
var dir = Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"test-{Guid.NewGuid():N}.scadabundle");
File.WriteAllBytes(path, bytes);
SetField(instance, "_bundleTempPath", path);
SetField(instance, "_session", null);
SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase);
SetField(instance, "_failedUnlockAttempts", 0);
}
}
@@ -0,0 +1,188 @@
using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
using ExecutionTreePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ExecutionTreePage;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
/// feature, Task 10). The page is reached via the "View execution chain"
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
/// and hands the flat node list to the <c>ExecutionTree</c> component.
/// </summary>
public class ExecutionTreePageTests : BunitContext
{
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
Services.AddSingleton(_queryService);
if (!string.IsNullOrEmpty(query))
{
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"/audit/execution-tree?{query}");
}
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<ExecutionTreePage>(0);
builder.CloseComponent();
})));
return host.FindComponent<ExecutionTreePage>();
}
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
=> new(
id, parent, rowCount,
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
rowCount == 0 ? null : "plant-a",
rowCount == 0 ? null : "boiler-3",
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
[Fact]
public void NavigateWithExecutionId_CallsService_AndRendersTree()
{
var root = Guid.Parse("11111111-1111-1111-1111-111111111111");
var child = Guid.Parse("22222222-2222-2222-2222-222222222222");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
var cut = RenderPage($"executionId={child}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
});
}
[Fact]
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage(query: null, "Admin");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage("executionId=not-a-guid", "Admin");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
[Fact]
public void DoubleClickTreeNode_OpensExecutionDetailModal()
{
var root = Guid.Parse("33333333-3333-3333-3333-333333333333");
var child = Guid.Parse("44444444-4444-4444-4444-444444444444");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
// The modal loads the double-clicked execution's audit rows on open.
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Admin");
// The modal is absent until a node is activated.
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
var body = cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body");
body.DoubleClick();
cut.WaitForAssertion(() =>
Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == child),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void ClosingExecutionDetailModal_HidesIt()
{
var root = Guid.Parse("55555555-5555-5555-5555-555555555555");
var child = Guid.Parse("66666666-6666-6666-6666-666666666666");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Admin");
cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick();
cut.WaitForAssertion(() =>
Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
cut.Find("[data-test=\"execution-detail-close\"]").Click();
cut.WaitForAssertion(() =>
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
}
[Fact]
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
{
var attributes = typeof(ExecutionTreePage)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
}
@@ -0,0 +1,243 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
///
/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
/// <see cref="CommunicationService"/> is a concrete class whose outbox calls
/// route through an injected notification-outbox <see cref="IActorRef"/>; the
/// tests reuse the scripted-actor seam established by the Notification Report
/// page tests (see <c>NotificationReportPageTests</c>).
/// </summary>
public class HealthPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("health-page-tests");
private readonly CommunicationService _comms;
// Mutable scripted reply — individual tests can override before rendering.
private NotificationKpiResponse _kpiReply =
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// that target the Site Call tiles override this before rendering.
private SiteCallKpiResponse _siteCallKpiReply =
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
StuckCount: 5);
public HealthPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
var aggregator = Substitute.For<ICentralHealthAggregator>();
aggregator.GetAllSiteStates()
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo);
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
// the existing assertions (Notification Outbox tiles, Online/Offline
// counts) keep passing; tests that target the Audit tiles set their
// own substitute.
var auditService = Substitute.For<IAuditLogQueryService>();
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
Services.AddSingleton(auditService);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void Renders_OutboxKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains("Stuck", cut.Markup);
Assert.Contains("Parked", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">12<", cut.Markup); // QueueDepth
Assert.Contains(">4<", cut.Markup); // StuckCount
Assert.Contains(">3<", cut.Markup); // ParkedCount
});
}
[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
var cut = Render<HealthPage>();
var link = cut.Find("a[href='/notifications/kpis']");
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void Renders_AuditKpiTiles_WithValues()
{
// Override the default empty snapshot — this test wants concrete values
// to land in the three Audit tiles.
var auditService = Substitute.For<IAuditLogQueryService>();
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
TotalEventsLastHour: 250,
ErrorEventsLastHour: 5,
BacklogTotal: 17,
AsOfUtc: DateTime.UtcNow)));
Services.AddSingleton(auditService);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// The three audit tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
// Volume shows the formatted thousand-separator value.
Assert.Contains("250", cut.Markup);
// Backlog renders 17.
Assert.Contains("17", cut.Markup);
});
}
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Site Calls", cut.Markup);
// The three Site Call tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">9<", cut.Markup); // BufferedCount
Assert.Contains(">5<", cut.Markup); // StuckCount
Assert.Contains(">2<", cut.Markup); // ParkedCount
});
}
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
var cut = Render<HealthPage>();
var link = cut.Find("a[href='/site-calls/report']");
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
_siteCallKpiReply = new SiteCallKpiResponse(
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash and the
// inline error message surfaces.
Assert.Contains("Site Calls", cut.Markup);
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
_kpiReply = new NotificationKpiResponse(
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash.
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to the KPI request
/// with the test's currently-scripted response.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(HealthPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
}
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
}
}
}
@@ -0,0 +1,166 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Notification KPIs page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. Both the global and
/// per-site KPI calls route through an injected <see cref="IActorRef"/> (the
/// notification-outbox proxy), so the tests wire a real, lightweight
/// <see cref="ActorSystem"/> with a scripted <see cref="ReceiveActor"/> that
/// answers both <see cref="NotificationKpiRequest"/> and
/// <see cref="PerSiteNotificationKpiRequest"/> — the same seam
/// <c>SetNotificationOutbox</c> exists for.
/// </summary>
public class NotificationKpisPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-tests");
private readonly CommunicationService _comms;
// Mutable scripted replies — individual tests can override before rendering.
private NotificationKpiResponse _kpiReply =
new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1,
DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9));
private PerSiteNotificationKpiResponse _perSiteReply =
new("p", true, null, new List<SiteNotificationKpiSnapshot>
{
new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0,
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
});
public NotificationKpisPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(NotificationKpisPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void RendersGlobalTilesAndPerSiteRows()
{
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains("7", cut.Markup); // global tile value
// Per-site row — site identifier "plant-a" resolves to its friendly name.
Assert.Contains("Plant A", cut.Markup);
});
}
[Fact]
public void ShowsKpiError_WhenGlobalKpiQueryFails()
{
_kpiReply = new NotificationKpiResponse(
"k", false, "kpi down", 0, 0, 0, 0, null);
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
}
[Fact]
public void ShowsPerSiteError_WhenPerSiteKpiQueryFails()
{
// Only the per-site path errors — the global KPI reply stays successful.
_perSiteReply = new PerSiteNotificationKpiResponse(
"p", false, "per-site down", new List<SiteNotificationKpiSnapshot>());
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Per-site KPIs unavailable: per-site down", cut.Markup);
// The two error paths are isolated — the global KPI alert (whose markup
// opens ">KPIs unavailable:", without the "Per-site " prefix) must not appear.
Assert.DoesNotContain(">KPIs unavailable:", cut.Markup);
});
}
[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
_perSiteReply = new PerSiteNotificationKpiResponse(
"p", true, null, new List<SiteNotificationKpiSnapshot>());
var cut = Render<NotificationKpisPage>();
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to each KPI message
/// type with the test's currently-scripted response.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationKpisPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
}
}
}
@@ -0,0 +1,110 @@
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.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using NotificationListsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationLists;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the standalone Notification Lists page (Task 7).
/// </summary>
public class NotificationListsPageTests : BunitContext
{
private void WireAuthAndDialog()
{
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Design"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void RendersNotificationListRows()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllNotificationListsAsync()
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
new List<NotificationList> { new("Ops On-Call") { Id = 1 } }));
repo.GetRecipientsByListIdAsync(1)
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(
new List<NotificationRecipient> { new("Jane", "jane@example.com") }));
Services.AddSingleton(repo);
WireAuthAndDialog();
var cut = Render<NotificationListsPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Ops On-Call", cut.Markup);
Assert.Contains("jane@example.com", cut.Markup);
});
}
[Fact]
public void ShowsEmptyState_WhenNoLists()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllNotificationListsAsync()
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
new List<NotificationList>()));
Services.AddSingleton(repo);
WireAuthAndDialog();
var cut = Render<NotificationListsPage>();
cut.WaitForAssertion(() =>
Assert.Contains("No notification lists", cut.Markup));
}
[Fact]
public void DeleteList_ConfirmsThenDeletesAndReloads()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllNotificationListsAsync()
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(
new List<NotificationList> { new("Ops On-Call") { Id = 1 } }));
repo.GetRecipientsByListIdAsync(1)
.Returns(Task.FromResult<IReadOnlyList<NotificationRecipient>>(
new List<NotificationRecipient>()));
Services.AddSingleton(repo);
WireAuthAndDialog();
var cut = Render<NotificationListsPage>();
cut.WaitForState(() => cut.Markup.Contains("Ops On-Call"));
var deleteButton = cut.FindAll("tbody tr button")
.First(b => b.TextContent.Contains("Delete"));
deleteButton.Click();
cut.WaitForAssertion(() =>
{
repo.Received().DeleteNotificationListAsync(1);
repo.Received().SaveChangesAsync();
// Reload re-invokes the list query (once on init, once after delete).
repo.Received(2).GetAllNotificationListsAsync();
});
}
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,292 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for the Notification Report row-detail modal — double-clicking a
/// notification row opens a Bootstrap modal showing that notification's full,
/// untruncated details.
///
/// Mirrors <see cref="NotificationReportPageTests"/>'s seam: the report's
/// <see cref="CommunicationService"/> calls route through an injected scripted
/// actor (the notification-outbox proxy).
/// </summary>
public class NotificationReportDetailModalTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
private readonly CommunicationService _comms;
private NotificationDetailResponse _detailReply =
new("d", true, null, new NotificationDetail(
NotificationId: "notif-aaaaaaaa-1111-full-id",
Type: "Email",
ListName: "Ops On-Call",
Subject: "Pump fault at Plant-A",
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
Status: "Parked",
RetryCount: 3,
LastError: "SMTP timeout connecting to mail relay",
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
TypeData: null,
SourceSiteId: "plant-a",
SourceInstanceId: "Pump-001",
SourceScript: "PumpFault.csx",
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
NextAttemptAt: null,
DeliveredAt: null));
private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary>
{
new("notif-aaaaaaaa-1111-full-id", "Email", "Ops On-Call", "Pump fault at Plant-A",
"Parked", RetryCount: 3, LastError: "SMTP timeout connecting to mail relay",
SourceSiteId: "plant-a", SourceInstanceId: "Pump-001",
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
DeliveredAt: null, IsStuck: true),
new("notif-bbbbbbbb-2222-full-id", "Email", "Maintenance", "Daily summary",
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
public NotificationReportDetailModalTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
[Fact]
public void DoubleClickRow_OpensDetailModal()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
// No modal initially.
Assert.Empty(cut.FindAll(".modal.show"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("Pump fault at Plant-A", modal.TextContent);
Assert.Contains("Ops On-Call", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsFullNotificationId_NotTruncated()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
// The grid renders ShortId(...) (first 12 chars); the modal must show
// the complete identifier.
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
});
}
[Fact]
public void CloseButton_DismissesModal()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
var closeButton = cut.Find(".modal.show .modal-footer button");
closeButton.Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
[Fact]
public void Modal_ShowsLastError_WhenPresent()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("SMTP timeout connecting to mail relay", modal.TextContent);
});
}
[Fact]
public void Modal_FetchesAndShowsBody()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains(
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
modal.TextContent);
});
}
[Fact]
public void Modal_ShowsRecipients_FromResolvedTargets()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("ops@example.com", modal.TextContent);
Assert.Contains("oncall@example.com", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
{
_detailReply = _detailReply with
{
Detail = _detailReply.Detail! with { ResolvedTargets = null },
};
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("Not yet resolved", modal.TextContent);
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("at delivery time", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsError_WhenDetailFetchFails()
{
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
// The error surfaces in the body/recipient sections...
Assert.Contains("detail store unavailable", modal.TextContent);
// ...but the summary fields (from the grid row) still render.
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
{
CorrelationId = r.CorrelationId,
}));
Receive<RetryNotificationRequest>(r =>
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
Receive<DiscardNotificationRequest>(r =>
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)));
}
}
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,281 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Notification Report page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The report calls all
/// route through an injected <see cref="IActorRef"/> (the notification-outbox
/// proxy), so the tests wire a real, lightweight <see cref="ActorSystem"/> with a
/// scripted <see cref="ReceiveActor"/> that replies with fixed responses — the
/// same seam <c>SetNotificationOutbox</c> exists for.
/// </summary>
public class NotificationReportPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("notif-report-tests");
private readonly CommunicationService _comms;
// Mutable scripted reply — individual tests can override before rendering.
private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary>
{
new("notif-aaaaaaaa-1111", "Email", "Ops On-Call", "Pump fault at Plant-A",
"Parked", RetryCount: 3, LastError: "SMTP timeout", SourceSiteId: "plant-a",
SourceInstanceId: "Pump-001", CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
DeliveredAt: null, IsStuck: true),
new("notif-bbbbbbbb-2222", "Email", "Maintenance", "Daily summary",
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
// Records the most recent retry/discard requests the actor received.
private readonly List<RetryNotificationRequest> _retryRequests = new();
private readonly List<DiscardNotificationRequest> _discardRequests = new();
public NotificationReportPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
// CentralUI-028: the page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(NotificationReportPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void Renders_NotificationRows()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Pump fault at Plant-A", cut.Markup);
Assert.Contains("Daily summary", cut.Markup);
Assert.Contains("Ops On-Call", cut.Markup);
});
}
[Fact]
public void StuckRow_IsBadged()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
var stuckRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
// The stuck row carries a visible "Stuck" badge.
Assert.Contains("badge", stuckRow.InnerHtml);
Assert.Contains("Stuck", stuckRow.TextContent);
});
}
[Fact]
public void ClickRetry_OnParkedRow_CallsRetryNotification()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var retryButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"));
retryButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_retryRequests);
Assert.Equal("notif-aaaaaaaa-1111", _retryRequests[0].NotificationId);
});
}
[Fact]
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var discardButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Discard"));
discardButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_discardRequests);
Assert.Equal("notif-aaaaaaaa-1111", _discardRequests[0].NotificationId);
});
}
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
_queryReply = new NotificationOutboxQueryResponse(
"q", false, "outbox query backend unavailable",
new List<NotificationSummary>(), TotalCount: 0);
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
Assert.Contains("outbox query backend unavailable", cut.Markup));
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
// "View audit history" link to /audit/log?correlationId={NotificationId}.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
// Both rows (Parked + Delivered) must surface the link — the drill-in
// is row-scope, not status-scope. We pin the parked row's href to the
// canonical correlationId-deep-link shape.
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(link);
Assert.Equal(
"/audit/log?correlationId=notif-aaaaaaaa-1111",
link!.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
var deliveredRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Daily summary"));
var deliveredLink = deliveredRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(deliveredLink);
Assert.Equal(
"/audit/log?correlationId=notif-bbbbbbbb-2222",
deliveredLink!.GetAttribute("href"));
});
}
[Fact]
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
{
// The drill-in is a plain <a href> — browser-native navigation, not a
// Blazor onclick handler — so this test verifies the rendered anchor's
// attributes are exactly what a browser would follow: href, role, and
// human-visible text. (Triggering bUnit's .Click() on a bare anchor
// raises MissingEventHandlerException because there is no onclick
// handler to invoke; the navigation contract lives in the <a> markup.)
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]")!;
Assert.Equal("a", link.TagName, ignoreCase: true);
Assert.Equal("/audit/log?correlationId=notif-aaaaaaaa-1111", link.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to each outbox message
/// type with the test's currently-scripted response.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationReportPageTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<RetryNotificationRequest>(r =>
{
test._retryRequests.Add(r);
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
});
Receive<DiscardNotificationRequest>(r =>
{
test._discardRequests.Add(r);
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
});
}
}
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,250 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Transport;
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
/// fills the remaining gaps the finding called out — unrecognised values, case
/// handling, and the no-query-string default for the Transport wizard.
/// </summary>
public sealed class QueryStringDrillInTests
{
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
// -----------------------------------------------------------------
// SiteCallsReport — ?status=
// -----------------------------------------------------------------
[Fact]
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
{
// The dropdown options use canonical casing ("Parked"). The KPI tiles
// emit canonical, but a hand-crafted ?status=parked URL must still seed
// the filter — the parser is case-insensitive and the seeded value uses
// the canonical casing so the <select> can bind it.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=parked");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
// Normalised to canonical casing (the dropdown's option text), not
// the URL's raw "parked".
Assert.Equal("Parked", ctx.QueryRequests[0].StatusFilter);
});
}
[Fact]
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
{
// Lax parsing: an unrecognised status value is ignored, leaving the
// filter empty so the page loads unfiltered. Mirrors AuditLogPage's
// drill-in convention — a hand-crafted bad URL must not break the page.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=NotARealStatus");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.Null(ctx.QueryRequests[0].StatusFilter);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
[Fact]
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
{
// bool.TryParse fails for "yes"/"1" — the parser drops the value and
// leaves StuckOnly = false, mirroring the unrecognised-status path.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=yes");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
// -----------------------------------------------------------------
// TransportImport — no query-string parameters on this route
// -----------------------------------------------------------------
/// <summary>
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
/// test pins that contract: navigating with an unrecognised query-string
/// param does not throw and does not change the initial step.
/// </summary>
[Fact]
public void TransportImport_UnrecognisedQueryStringParam_DoesNotChangeInitialStep()
{
using var ctx = new TransportImportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/design/transport/import?bundleImportId=11111111-1111-1111-1111-111111111111&foo=bar");
var cut = ctx.Render<TransportImportPage>();
// The wizard starts at Upload regardless of any drill-in query string —
// the page has no [Parameter]-bound properties so unknown keys are
// silently ignored by Blazor's parameter binding.
var step = (TransportImportPage.ImportWizardStep)typeof(TransportImportPage)
.GetField("_step",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(cut.Instance)!;
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
// And the Step-1 InputFile control is rendered — the page came up clean.
Assert.NotNull(cut.Find("input[type='file']"));
}
// -----------------------------------------------------------------
// Test-scoped fixtures — kept inside this file to bound the diff.
// The existing page-level test files have their own larger fixtures;
// these copies are intentionally minimal (only what the drill-in
// tests need).
// -----------------------------------------------------------------
private sealed class SiteCallsReportFixture : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("qs-drillin-tests");
public readonly CommunicationService Comms;
public readonly List<SiteCallQueryRequest> QueryRequests = new();
public SiteCallsReportFixture()
{
Comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
Comms.SetSiteCallAudit(auditProxy);
Services.AddSingleton(Comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
}
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
{
Receive<SiteCallQueryRequest>(req =>
{
fixture.QueryRequests.Add(req);
Sender.Tell(new SiteCallQueryResponse(
req.CorrelationId, true, null,
new List<SiteCallSummary>(), null, null));
});
}
}
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
private sealed class TransportImportFixture : BunitContext
{
public TransportImportFixture()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
.Options;
var dbContext = new ScadaBridgeDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
var claims = new List<Claim>
{
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.UsernameClaimType, "alice"),
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Admin"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
}
}
}
@@ -0,0 +1,558 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
/// </summary>
public class SiteCallsReportPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
private readonly CommunicationService _comms;
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
// Mutable scripted reply — individual tests can override before rendering.
private SiteCallQueryResponse _queryReply = new(
"q", true, null,
new List<SiteCallSummary>
{
new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
TerminalAtUtc: null, IsStuck: true),
new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed",
RetryCount: 1, LastError: "constraint violation", HttpStatus: null,
CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2),
TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false),
},
NextAfterCreatedAtUtc: null,
NextAfterId: null);
// Records the most recent retry/discard requests the actor received.
private readonly List<SiteCallQueryRequest> _queryRequests = new();
private readonly List<RetrySiteCallRequest> _retryRequests = new();
private readonly List<DiscardSiteCallRequest> _discardRequests = new();
// Scripted relay responses — overridable per test.
private RetrySiteCallResponse _retryReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
private DiscardSiteCallResponse _discardReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
public SiteCallsReportPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(auditProxy);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(SiteCallsReportPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void Renders_SiteCallRows()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("ERP.GetOrder", cut.Markup);
Assert.Contains("Historian.Write", cut.Markup);
});
}
[Fact]
public void StuckRow_IsBadged()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
var stuckRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
Assert.Contains("badge", stuckRow.InnerHtml);
Assert.Contains("Stuck", stuckRow.TextContent);
});
}
[Fact]
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var failedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Historian.Write"));
// The Parked row carries Retry + Discard buttons.
Assert.Contains(parkedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Retry"));
Assert.Contains(parkedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Discard"));
// The Failed row carries neither — Retry/Discard are Parked-only.
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Retry"));
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
b => b.TextContent.Contains("Discard"));
}
[Fact]
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var retryButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"));
retryButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_retryRequests);
Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId);
// The relay carries the owning site so central can route it.
Assert.Equal("plant-a", _retryRequests[0].SourceSite);
});
}
[Fact]
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var discardButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Discard"));
discardButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_discardRequests);
Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId);
Assert.Equal("plant-a", _discardRequests[0].SourceSite);
});
}
[Fact]
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
{
// The relay never reached the owning site — a transient transport
// condition, surfaced distinctly from a generic failure.
_retryReply = new RetrySiteCallResponse(
"q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false,
ErrorMessage: "Site plant-a is offline — relay not delivered.");
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"))
.Click();
cut.WaitForAssertion(() =>
Assert.Contains("offline", cut.Markup));
}
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
_queryReply = new SiteCallQueryResponse(
"q", false, "site call query backend unavailable",
new List<SiteCallSummary>(), null, null);
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
Assert.Contains("site call query backend unavailable", cut.Markup));
}
// ─────────────────────────────────────────────────────────────────────────
// Drill-in — every row carries a "View audit history" link to
// /audit/log?correlationId={TrackedOperationId}.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
{
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// Both rows (Parked + Failed) surface the link — the drill-in is
// row-scope, not status-scope.
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(link);
Assert.Equal(
$"/audit/log?correlationId={ParkedId}",
link!.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
var failedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Historian.Write"));
var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(failedLink);
Assert.Equal(
$"/audit/log?correlationId={FailedId}",
failedLink!.GetAttribute("href"));
});
}
// ─────────────────────────────────────────────────────────────────────────
// Keyset paging — Next is driven by the response's NextAfter* cursor, not by
// page numbers; the request echoes the cursor back to the actor.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void Paging_NextButton_HiddenWhenNoFurtherPage()
{
// The default reply returns 2 rows and no NextAfter* cursor — there is no
// further page, so Next is disabled.
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var next = cut.Find("[data-test='site-calls-next']");
Assert.True(next.HasAttribute("disabled"));
var prev = cut.Find("[data-test='site-calls-prev']");
Assert.True(prev.HasAttribute("disabled"));
}
[Fact]
public void Paging_NextButton_AdvancesUsingKeysetCursor()
{
// A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live
// and, when clicked, the follow-up query carries that cursor.
var firstPage = new List<SiteCallSummary>();
for (var i = 0; i < 50; i++)
{
firstPage.Add(new SiteCallSummary(
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
RetryCount: 0, LastError: null, HttpStatus: 200,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
}
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
_queryReply = new SiteCallQueryResponse(
"q", true, null, firstPage,
NextAfterCreatedAtUtc: cursorCreated,
NextAfterId: cursorId);
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
var next = cut.Find("[data-test='site-calls-next']");
Assert.False(next.HasAttribute("disabled"));
next.Click();
cut.WaitForAssertion(() =>
{
// Two queries fired: the initial load and the Next click. The second
// carries the keyset cursor echoed by the first response.
Assert.Equal(2, _queryRequests.Count);
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
Assert.Equal(cursorId, _queryRequests[1].AfterId);
});
}
[Fact]
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
{
// The keyset back-stack is the trickiest paging path: Next pushes the
// current cursor, Prev pops it and refetches that prior page. Page 1 is
// opened with the empty (null, null) cursor, so after Next→Previous the
// follow-up query must carry (null, null) again.
var firstPage = new List<SiteCallSummary>();
for (var i = 0; i < 50; i++)
{
firstPage.Add(new SiteCallSummary(
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
RetryCount: 0, LastError: null, HttpStatus: 200,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
}
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
_queryReply = new SiteCallQueryResponse(
"q", true, null, firstPage,
NextAfterCreatedAtUtc: cursorCreated,
NextAfterId: cursorId);
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
// Step forward — query 2 carries the keyset cursor.
var next = cut.Find("[data-test='site-calls-next']");
next.Click();
cut.WaitForAssertion(() =>
{
Assert.Equal(2, _queryRequests.Count);
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
});
// Previous is now live (the back-stack has one entry); click it.
var prev = cut.Find("[data-test='site-calls-prev']");
Assert.False(prev.HasAttribute("disabled"));
prev.Click();
cut.WaitForAssertion(() =>
{
// Query 3 is the Previous refetch — the back-stack popped the page-1
// cursor, which is the empty (null, null) first-page cursor.
Assert.Equal(3, _queryRequests.Count);
Assert.Null(_queryRequests[2].AfterCreatedAtUtc);
Assert.Null(_queryRequests[2].AfterId);
// Back on page 1, the back-stack is empty again so Previous re-disables.
Assert.True(cut.Find("[data-test='site-calls-prev']").HasAttribute("disabled"));
});
}
[Fact]
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
{
// NotParked is a definitive answer from the site (nothing to do), not a
// failure — it surfaces as a single info toast, never an error. This
// also guards the single-toast contract: a non-Applied outcome must
// produce exactly one toast.
_retryReply = new RetrySiteCallResponse(
"q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true,
ErrorMessage: "The cached call is no longer parked.");
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"))
.Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("no longer parked", cut.Markup);
// Exactly one toast — the ShowRelayOutcome switch owns the single
// toast; no second (error) toast piggybacks on the same response.
Assert.Single(cut.FindAll(".toast"));
});
}
// ─────────────────────────────────────────────────────────────────────────
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
// params must seed the filter BEFORE the first query so the initial grid load
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
// The Parked KPI tile emits ?status=Parked — set the URI before render.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=Parked");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first (and only) query the page issues carries the Parked
// status filter — the grid load is pre-filtered, not unfiltered.
Assert.Single(_queryRequests);
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
// The Status <select> control reflects the seeded value so the
// operator sees the filter and can Clear it.
var statusSelect = cut.Find("#sc-status");
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
});
}
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
// The Stuck KPI tile emits ?stuck=true.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=true");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first query carries StuckOnly = true.
Assert.Single(_queryRequests);
Assert.True(_queryRequests[0].StuckOnly);
// The "Stuck only" checkbox is checked.
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.True(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
// No drill-in params — the page loads exactly as before: an unfiltered
// query and no status/stuck filter set on the controls.
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(_queryRequests);
Assert.Null(_queryRequests[0].StatusFilter);
Assert.False(_queryRequests[0].StuckOnly);
var statusSelect = cut.Find("#sc-status");
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.False(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// Plant B rows in the grid, even though the query response carried both.
// Last AuthenticationStateProvider registration wins on resolution.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("Username", "scoped"),
new Claim(ClaimTypes.Role, "Deployment"),
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
}, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
TimeSpan.FromSeconds(2));
var rows = cut.FindAll("table tbody tr");
Assert.Single(rows);
// Plant A row only; Plant B (FailedId) row must be filtered out.
Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to each message type with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
{
Receive<SiteCallQueryRequest>(r =>
{
test._queryRequests.Add(r);
Sender.Tell(test._queryReply);
});
Receive<RetrySiteCallRequest>(r =>
{
test._retryRequests.Add(r);
Sender.Tell(test._retryReply);
});
Receive<DiscardSiteCallRequest>(r =>
{
test._discardRequests.Add(r);
Sender.Tell(test._discardReply);
});
}
}
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using SmtpConfigurationPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.SmtpConfiguration;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the SMTP Configuration page — specifically the TlsMode
/// field added so the UI exposes all five user-relevant SmtpConfiguration fields.
/// </summary>
public class SmtpConfigurationPageTests : BunitContext
{
private void WireAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private static SmtpConfiguration Sample() =>
new("smtp.example.com", "Basic", "noreply@example.com")
{
Id = 1,
Port = 587,
TlsMode = "StartTLS",
Credentials = "user:pass",
};
[Fact]
public void EditForm_RendersTlsModeSelectWithAllThreeModes()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllSmtpConfigurationsAsync()
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
new List<SmtpConfiguration> { Sample() }));
Services.AddSingleton(repo);
WireAuth();
var cut = Render<SmtpConfigurationPage>();
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
cut.WaitForAssertion(() =>
{
var selects = cut.FindAll("select");
var tlsSelect = selects.Single(s => s.QuerySelectorAll("option")
.Any(o => o.TextContent == "StartTLS"));
var modes = tlsSelect.QuerySelectorAll("option").Select(o => o.TextContent).ToList();
Assert.Equal(new[] { "None", "StartTLS", "SSL" }, modes);
});
}
[Fact]
public void ReadOnlyView_ShowsTlsMode()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllSmtpConfigurationsAsync()
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
new List<SmtpConfiguration> { Sample() }));
Services.AddSingleton(repo);
WireAuth();
var cut = Render<SmtpConfigurationPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("TLS Mode", cut.Markup);
Assert.Contains("StartTLS", cut.Markup);
});
}
[Fact]
public void SavingEdit_PersistsChosenTlsMode()
{
var config = Sample();
var repo = Substitute.For<INotificationRepository>();
repo.GetAllSmtpConfigurationsAsync()
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
new List<SmtpConfiguration> { config }));
Services.AddSingleton(repo);
WireAuth();
var cut = Render<SmtpConfigurationPage>();
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
var tlsSelect = cut.FindAll("select")
.Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == "StartTLS"));
tlsSelect.Change("SSL");
cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click();
cut.WaitForAssertion(() =>
{
repo.Received().UpdateSmtpConfigurationAsync(
Arg.Is<SmtpConfiguration>(c => c.TlsMode == "SSL"));
repo.Received().SaveChangesAsync();
});
}
}
@@ -0,0 +1,106 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
public class JsonSchemaShapeParserTests
{
// ── JSON Schema (post-migration) ─────────────────────────────────────────
[Fact]
public void Parameters_JsonSchema_ScalarsAndRequired()
{
const string json = """
{"type":"object","properties":{
"id":{"type":"integer"},
"label":{"type":"string"},
"active":{"type":"boolean"}
},"required":["id","active"]}
""";
var result = JsonSchemaShapeParser.ParseParameters(json);
Assert.Collection(result,
p => { Assert.Equal("id", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
p => { Assert.Equal("label", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); },
p => { Assert.Equal("active", p.Name); Assert.Equal("Boolean", p.Type); Assert.True(p.Required); });
}
[Fact]
public void Parameters_JsonSchema_ArrayOfStringsBecomesListString()
{
const string json = """
{"type":"object","properties":{
"tags":{"type":"array","items":{"type":"string"}}
}}
""";
var result = JsonSchemaShapeParser.ParseParameters(json);
var tags = Assert.Single(result);
Assert.Equal("tags", tags.Name);
Assert.Equal("List<String>", tags.Type);
Assert.False(tags.Required);
}
[Fact]
public void Return_JsonSchema_Number()
{
Assert.Equal("Float", JsonSchemaShapeParser.ParseReturnType(@"{""type"":""number""}"));
}
[Fact]
public void Return_JsonSchema_ArrayOfIntegers()
{
Assert.Equal("List<Integer>",
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""array"",""items"":{""type"":""integer""}}"));
}
// ── Legacy flat shape (pre-migration safety net) ─────────────────────────
[Fact]
public void Parameters_Legacy_FlatArrayStillParses()
{
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
var result = JsonSchemaShapeParser.ParseParameters(json);
Assert.Collection(result,
p => { Assert.Equal("x", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
p => { Assert.Equal("y", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); });
}
[Fact]
public void Return_Legacy_ListSentinelStillParses()
{
Assert.Equal("List<String>",
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""List"",""itemType"":""String""}"));
}
// ── Edge cases ────────────────────────────────────────────────────────────
[Fact]
public void Parameters_Null_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters(null));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(""));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(" "));
}
[Fact]
public void Parameters_Malformed_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters("{not json"));
Assert.Empty(JsonSchemaShapeParser.ParseParameters("42"));
}
[Fact]
public void Return_Null_ReturnsNull()
{
Assert.Null(JsonSchemaShapeParser.ParseReturnType(null));
Assert.Null(JsonSchemaShapeParser.ParseReturnType(""));
}
[Fact]
public void Parameters_SchemaWithNoProperties_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object""}"));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object"",""properties"":{}}"));
}
}
@@ -0,0 +1,86 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
/// <summary>
/// Regression tests for the <c>SandboxConsoleCapture</c> writer that the Test Run
/// sandbox installs on <c>Console.Out</c>/<c>Console.Error</c>. CentralUI-030
/// surfaced an intra-script concurrency hazard: a sandboxed script can fan out
/// work with <c>Task.WhenAll</c> / <c>Task.Run</c> and every child task inherits
/// the capture <c>StringWriter</c> via <c>AsyncLocal</c>; <c>StringWriter</c> is
/// not thread-safe, so concurrent writes could corrupt the buffer. These tests
/// drive the writer the same way Roslyn-hosted user code does.
/// </summary>
public class SandboxConsoleCaptureTests
{
/// <summary>
/// CentralUI-030: a capture scope shared across <c>Task.WhenAll</c> child
/// tasks must serialise writes so the resulting transcript contains exactly
/// the expected number of lines without character-level interleaving.
/// </summary>
[Fact]
public async Task BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer()
{
// The static install routes Console.Out through the singleton sandbox
// capture writer for the test process — this is idempotent and matches
// the way ScriptAnalysisService bootstraps the sandbox in production.
var (capture, _) = SandboxConsoleCapture.Install();
var buffer = new StringWriter();
const int taskCount = 32;
const int linesPerTask = 50;
const int expectedLines = taskCount * linesPerTask;
using (capture.BeginCapture(buffer))
{
// AsyncLocal flows the capture scope into each Task.Run, mirroring
// a sandboxed script doing `await Task.WhenAll(...)` over Tasks
// that each `Console.WriteLine`.
var tasks = Enumerable.Range(0, taskCount).Select(i => Task.Run(() =>
{
for (var j = 0; j < linesPerTask; j++)
{
Console.WriteLine($"task-{i}-line-{j}");
}
}));
await Task.WhenAll(tasks);
}
var captured = buffer.ToString();
// Without the lock, concurrent StringWriter.WriteLine can drop or
// interleave characters and produce malformed lines / a wrong count.
// We assert the exact line count and that every emitted token is
// present on a line of its own — both fail under the unprotected
// implementation.
var lines = captured.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(expectedLines, lines.Length);
for (var i = 0; i < taskCount; i++)
{
for (var j = 0; j < linesPerTask; j++)
{
Assert.Contains($"task-{i}-line-{j}", lines);
}
}
}
/// <summary>
/// Sanity check: the most basic capture happy-path still works after the
/// CentralUI-030 lock was introduced.
/// </summary>
[Fact]
public void BeginCapture_SingleThreadedWrites_AreCaptured()
{
var (capture, _) = SandboxConsoleCapture.Install();
var buffer = new StringWriter();
using (capture.BeginCapture(buffer))
{
Console.WriteLine("hello");
Console.Write("world");
}
Assert.Contains("hello", buffer.ToString());
Assert.Contains("world", buffer.ToString());
}
}
@@ -0,0 +1,91 @@
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
/// <summary>
/// Regression tests for CentralUI-013. <c>ResolveCalledShape</c> resolved shared
/// script shapes with <c>_sharedScripts.GetShapesAsync().GetAwaiter().GetResult()</c>
/// — a sync-over-async block on a request thread that risks thread-pool
/// starvation and deadlock. <c>Hover</c> and <c>SignatureHelp</c> were synchronous
/// purely to accommodate that block. The fix makes both methods async and
/// <c>await</c>s the catalog.
/// </summary>
public class ScriptAnalysisAsyncResolveTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly IServiceProvider _services = Substitute.For<IServiceProvider>();
private readonly ScriptAnalysisService _svc;
public ScriptAnalysisAsyncResolveTests()
{
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
_svc = new ScriptAnalysisService(_catalog, _cache, _services);
}
private static ScriptShape Shape(string name, params ParameterShape[] ps) => new(name, ps, null);
private static ParameterShape Param(string name, string type) => new(name, type, true);
[Fact]
public async Task HoverAsync_OnSharedCallName_AwaitsCatalog_AndResolvesShape()
{
// The catalog only completes after yielding — a truly asynchronous
// source. The fixed Hover awaits it instead of blocking.
_catalog.GetShapesAsync().Returns(async _ =>
{
await Task.Yield();
return (IReadOnlyList<ScriptShape>)new[]
{
Shape("Aggregate", Param("window", "Integer")),
};
});
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var r = Scripts.CallShared(\"Aggregate\");",
Line: 1,
Column: 30));
Assert.NotNull(resp.Markdown);
Assert.Contains("shared script", resp.Markdown);
Assert.Contains("Aggregate", resp.Markdown);
}
[Fact]
public async Task SignatureHelpAsync_InsideSharedCall_AwaitsCatalog_AndResolvesParameters()
{
_catalog.GetShapesAsync().Returns(async _ =>
{
await Task.Yield();
return (IReadOnlyList<ScriptShape>)new[]
{
Shape("Aggregate", Param("window", "Integer"), Param("mode", "String")),
};
});
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = Scripts.CallShared(\"Aggregate\", ",
Line: 1,
Column: 41));
Assert.NotNull(resp.Label);
Assert.Contains("Aggregate", resp.Label!);
Assert.Equal(2, resp.Parameters!.Count);
}
[Fact]
public void HoverAndSignatureHelp_AreAsync_NotSyncOverAsync()
{
// Structural guard: the methods must return Task so the catalog can be
// awaited rather than blocked with .GetAwaiter().GetResult().
var hover = typeof(ScriptAnalysisService).GetMethod(nameof(ScriptAnalysisService.Hover));
var sigHelp = typeof(ScriptAnalysisService).GetMethod(nameof(ScriptAnalysisService.SignatureHelp));
Assert.NotNull(hover);
Assert.NotNull(sigHelp);
Assert.Equal(typeof(Task<HoverResponse>), hover!.ReturnType);
Assert.Equal(typeof(Task<SignatureHelpResponse>), sigHelp!.ReturnType);
}
}
@@ -0,0 +1,589 @@
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly IServiceProvider _services = Substitute.For<IServiceProvider>();
private readonly ScriptAnalysisService _svc;
private static ScriptShape Shape(string name, params ParameterShape[] ps) =>
new(name, ps, null);
private static ParameterShape Param(string name, string type = "String", bool required = true) =>
new(name, type, required);
public ScriptAnalysisServiceTests()
{
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
_svc = new ScriptAnalysisService(_catalog, _cache, _services);
}
// ── Diagnose ──────────────────────────────────────────────────────────
[Fact]
public void EmptyCode_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest(""));
Assert.Empty(resp.Markers);
}
[Fact]
public void CleanScript_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;"));
Assert.Empty(resp.Markers);
}
[Fact]
public void MissingSemicolon_ReportsRoslynDiagnostic()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n"));
Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS"));
}
[Fact]
public void ForbiddenUsingDirective_RaisesSCADA001()
{
var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO"));
}
[Theory]
[InlineData("System.Diagnostics")]
[InlineData("System.Reflection")]
[InlineData("System.Net")]
public void ForbiddenUsing_AllBannedNamespaces(string ns)
{
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
}
[Fact]
public void ForbiddenTypeUsage_ResolvesViaSemanticModel()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.IO; var s = File.ReadAllText(\"x\");"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
}
[Fact]
public void UserIdentifierNamedFile_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var File = \"hello\"; return File.Length;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void UserIdentifierNamedThread_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var Thread = 42; return Thread;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void DiagnosticsAreCached_SecondCallSkipsRecompile()
{
var req = new DiagnoseRequest("using System.IO;");
var first = _svc.Diagnose(req);
var second = _svc.Diagnose(req);
Assert.Same(first, second);
}
[Fact]
public void DifferentCode_GetsDifferentCacheEntries()
{
var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;"));
var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;"));
Assert.NotSame(a, b);
}
[Fact]
public void UnknownParameterKey_RaisesSCADA003()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"typo\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'"));
}
[Fact]
public void DeclaredParameterKey_NoMarker()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"name\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003");
}
// ── Completions ───────────────────────────────────────────────────────
[Fact]
public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parameters[\"",
Line: 1,
Column: 21,
DeclaredParameters: new[] { "name", "temperature" });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter");
Assert.Contains(resp.Items, i => i.Label == "temperature");
}
[Fact]
public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet()
{
var siblings = new[] { Shape("SiblingA", Param("x")) };
var req = new CompletionsRequest(
CodeText: "var x = Instance.CallScript(\"",
Line: 1,
Column: 30,
SiblingScripts: siblings);
var resp = await _svc.CompleteAsync(req);
var item = Assert.Single(resp.Items, i => i.Label == "SiblingA");
Assert.Equal(4, item.InsertTextRules);
// The runtime call API takes args as an anonymous object — the snippet
// emits one member per declared parameter.
Assert.Contains("new { x = ${1:x} }", item.InsertText);
Assert.Contains("sibling script", item.Detail);
}
[Fact]
public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes()
{
_catalog.GetShapesAsync().Returns(new[]
{
Shape("GetWeather"),
Shape("Greet", Param("name"))
});
var req = new CompletionsRequest(
CodeText: "var x = Scripts.CallShared(\"",
Line: 1,
Column: 29);
var resp = await _svc.CompleteAsync(req);
// No-parameter shape: snippet just closes the call.
var weather = Assert.Single(resp.Items, i => i.Label == "GetWeather");
Assert.Equal("GetWeather\")", weather.InsertText);
// Parameterized shape: anonymous-object member per parameter.
var greet = Assert.Single(resp.Items, i => i.Label == "Greet");
Assert.Contains("new { name = ${1:name} }", greet.InsertText);
Assert.Contains("shared script", greet.Detail);
}
[Fact]
public async Task GeneralCompletion_ReturnsInScopeSymbols()
{
var req = new CompletionsRequest("var x = ", 1, 9);
var resp = await _svc.CompleteAsync(req);
// SandboxScriptHost globals are surfaced as in-scope symbols. The
// runtime call API is member-access — Scripts.CallShared / Instance.*
// — so the top-level globals are Parameters, Scripts, and Instance.
Assert.Contains(resp.Items, i => i.Label == "Parameters");
Assert.Contains(resp.Items, i => i.Label == "Scripts");
Assert.Contains(resp.Items, i => i.Label == "Instance");
}
// ── Hover ─────────────────────────────────────────────────────────────
[Fact]
public async Task Hover_OnSiblingName_ReturnsSignature()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
// Cursor inside the "Calc" name literal of Instance.CallScript("Calc", ...).
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var r = Instance.CallScript(\"Calc\", 1, 2);",
Line: 1,
Column: 32,
SiblingScripts: siblings));
Assert.NotNull(resp.Markdown);
Assert.Contains("sibling script", resp.Markdown);
Assert.Contains("Calc(x: Integer, y: Float): void", resp.Markdown);
}
[Fact]
public async Task Hover_OnUnrelatedToken_ReturnsNull()
{
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Markdown);
}
// ── Signature help ────────────────────────────────────────────────────
[Fact]
public async Task SignatureHelp_InsideCallScript_ReturnsParameterStrip()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = Instance.CallScript(\"Calc\", 1, ",
Line: 1,
Column: 40,
SiblingScripts: siblings));
Assert.Equal("Instance.CallScript(\"Calc\", x: Integer, y: Float)", resp.Label);
Assert.Equal(2, resp.Parameters!.Count);
Assert.Equal("x: Integer", resp.Parameters[0].Label);
Assert.Equal("y: Float", resp.Parameters[1].Label);
Assert.Equal(1, resp.ActiveParameter);
}
[Fact]
public async Task SignatureHelp_OutsideCall_ReturnsNull()
{
var resp = await _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Label);
}
// ── Format ────────────────────────────────────────────────────────────
[Fact]
public void Format_ScrambledCode_ReturnsPrettyPrinted()
{
var resp = _svc.Format(new FormatRequest("if(x){return 1;}else{return 2;}"));
// Roslyn's default formatter adds spaces around keywords/braces.
Assert.Contains("if (x)", resp.Code);
Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code);
}
[Fact]
public void Format_EmptyCode_ReturnsEmpty()
{
Assert.Equal("", _svc.Format(new FormatRequest("")).Code);
}
// ── Self / Children / Parent attribute completions ────────────────────
private static AttributeShape Attr(string name, string type = "String") => new(name, type);
private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null)
=> new(name, attrs ?? Array.Empty<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>());
[Fact]
public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Attributes[\"",
Line: 1,
Column: 21,
SelfAttributes: new[] { Attr("Temperature"), Attr("Setpoint", "Float") });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Temperature");
Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float"));
}
[Fact]
public async Task ChildAttribute_Literal_ReturnsChildAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"TempSensor\"].Attributes[\"",
Line: 1,
Column: 44,
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature"), Attr("Humidity") }) });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Temperature");
Assert.Contains(resp.Items, i => i.Label == "Humidity");
}
[Fact]
public async Task ParentAttribute_Literal_ReturnsParentAttributeNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parent.Attributes[\"",
Line: 1,
Column: 28,
Parent: Comp("Motor", attrs: new[] { Attr("SpeedRPM") }));
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "SpeedRPM");
}
[Fact]
public async Task ChildrenLiteral_ReturnsCompositionNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"",
Line: 1,
Column: 19,
Children: new[] { Comp("TempSensor"), Comp("PressureSensor") });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "TempSensor" && i.Detail == "composition");
Assert.Contains(resp.Items, i => i.Label == "PressureSensor");
}
[Fact]
public void UnknownSelfAttribute_RaisesSCADA006()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Attributes[\"Typo\"];",
SelfAttributes: new[] { Attr("Temperature") }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo"));
}
[Fact]
public void KnownSelfAttribute_NoMarker()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Attributes[\"Temperature\"];",
SelfAttributes: new[] { Attr("Temperature") }));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006");
}
[Fact]
public void UnknownChildAttribute_RaisesSCADA006()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Children[\"TempSensor\"].Attributes[\"Typo\"];",
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature") }) }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor"));
}
[Fact]
public void UnknownComposition_RaisesSCADA007()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Children[\"Unknown\"].Attributes[\"X\"];",
Children: new[] { Comp("TempSensor") }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown"));
}
[Fact]
public async Task ChildrenCallScript_ReturnsChildScripts()
{
var req = new CompletionsRequest(
CodeText: "var x = Children[\"TempSensor\"].CallScript(\"",
Line: 1,
Column: 44,
Children: new[]
{
Comp("TempSensor", scripts: new[] { Shape("Sample", Param("count", "Integer")) })
});
var resp = await _svc.CompleteAsync(req);
var sample = Assert.Single(resp.Items, i => i.Label == "Sample");
Assert.Contains("script on TempSensor", sample.Detail);
Assert.Contains("${1:count}", sample.InsertText);
}
[Fact]
public async Task ParentCallScript_ReturnsParentScripts()
{
var req = new CompletionsRequest(
CodeText: "var x = Parent.CallScript(\"",
Line: 1,
Column: 28,
Parent: Comp("Motor", scripts: new[] { Shape("Trip") }));
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Trip" && i.Detail.Contains("parent script"));
}
// ── Hover on Parameters["name"] ───────────────────────────────────────
[Fact]
public async Task Hover_OnParametersKey_ShowsDeclaredType()
{
var resp = await _svc.Hover(new HoverRequest(
CodeText: "var x = Parameters[\"name\"];",
Line: 1,
Column: 22,
DeclaredParameters: new[] { new ParameterShape("name", "String", true) }));
Assert.NotNull(resp.Markdown);
Assert.Contains("name", resp.Markdown);
Assert.Contains("String", resp.Markdown);
}
// ── CentralUI-001: trust-model gate before sandbox execution ──────────
[Fact]
public void Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002()
{
// A forbidden API reached by fully-qualified name (no `using`, no bare
// type identifier) must still be flagged — the pre-fix semantic check
// only inspected the leftmost identifier and missed this shape.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var d = System.IO.Directory.GetCurrentDirectory(); return d;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public async Task RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution()
{
// Regression test for CentralUI-001. RunInSandboxAsync used to execute any
// script that compiled, with no trust-model enforcement — so fully-qualified
// forbidden API code ran in the central host process. The fix gates execution
// on the forbidden-API analysis.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"var d = System.IO.Directory.GetCurrentDirectory(); return d;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind);
Assert.Contains("trust model", result.Error);
Assert.NotNull(result.Markers);
Assert.Contains(result.Markers!, m => m.Code is "SCADA001" or "SCADA002");
}
[Fact]
public async Task RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"using System.Diagnostics; var p = Process.GetCurrentProcess().Id; return p;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind);
}
[Fact]
public async Task RunInSandbox_CleanScript_StillRuns()
{
// The gate must not block a script that stays within the allowed surface.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest("return 21 * 2;", Parameters: null, TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("42", result.ReturnValueJson);
}
[Fact]
public void NotifyOutboxShape_DiagnosesClean()
{
// Notification Outbox: the sandbox Notify surface must be
// signature-faithful to production NotifyHelper/NotifyTarget —
// Send returns Task<string> (a NotificationId) and Status takes that
// id. A script using the new shape must compile clean in the sandbox,
// exactly as it would against the real site runtime.
var resp = _svc.Diagnose(new DiagnoseRequest(
"var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
"var st = await Notify.Status(id); " +
"return st.Status;"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public async Task RunInSandbox_NotifyOutboxShape_StillRuns()
{
// The new Notify shape must also run end-to-end in the no-op sandbox:
// Send yields a fake NotificationId, Status yields a placeholder
// NotificationDeliveryStatus.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"var id = await Notify.To(\"ops\").Send(\"subj\", \"body\"); " +
"var st = await Notify.Status(id); " +
"return st.Status;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("\"Unknown\"", result.ReturnValueJson);
}
[Fact]
public async Task RunInSandbox_CapturesConsoleOutput()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"System.Console.WriteLine(\"hello-sandbox\"); return 1;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Contains("hello-sandbox", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
{
// Regression test for CentralUI-003. RunInSandboxAsync used to redirect the
// process-global Console.Out/Error to a per-call StringWriter. While one run
// is mid-flight, any concurrent run's `finally` restores Console.Out to the
// ORIGINAL writer — so the long run loses every Console.WriteLine it makes
// after that point, and short runs cross-contaminate each other. The fix
// routes capture per-call via an AsyncLocal writer without mutating
// process-global Console state.
// A long-running script: writes its tag, then burns CPU, then writes again,
// repeatedly. While it spins, many short runs start and finish around it.
async Task<string> RunLong()
{
var code = @"
for (int i = 0; i < 40; i++)
{
System.Console.WriteLine(""LONG"");
long acc = 0;
for (long j = 0; j < 2_000_000; j++) acc += j;
System.Console.WriteLine(""LONG"" + acc);
}
return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
async Task<string> RunShort(int id)
{
var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
var longTask = RunLong();
var shortTasks = new List<Task<string>>();
for (var round = 0; round < 12; round++)
{
for (var k = 0; k < 4; k++)
shortTasks.Add(RunShort(round * 4 + k));
await Task.Yield();
}
var longOut = await longTask;
var shortOuts = await Task.WhenAll(shortTasks);
// The long run must have captured ALL 80 of its own writes (40 plain + 40 acc).
var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Count(l => l.StartsWith("LONG"));
Assert.Equal(80, longLines);
// No short run's output must have leaked into the long run's capture.
for (var i = 0; i < shortOuts.Length; i++)
Assert.DoesNotContain($"S{i}", longOut);
// Each short run captured exactly its own 30 lines and nothing else.
for (var i = 0; i < shortOuts.Length; i++)
{
var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(30, lines.Length);
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
}
}
}
@@ -0,0 +1,310 @@
using System.Text;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
/// service streams the filtered Audit Log query to a destination stream as
/// RFC 4180 CSV. These tests pin:
/// <list type="bullet">
/// <item>Header + body row count for a simple page.</item>
/// <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
/// <item>Null fields render as empty (no literal "null").</item>
/// <item>Row cap honoured + cap footer appended.</item>
/// <item>Cancellation tokens propagate mid-stream.</item>
/// </list>
/// </summary>
public class AuditLogExportServiceTests
{
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
=> new()
{
EventId = Guid.Parse(id),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a",
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = target,
Status = AuditStatus.Delivered,
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = error,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
[Fact]
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
{
var rows = new List<AuditEvent>
{
SimpleEvent("11111111-1111-1111-1111-111111111111"),
SimpleEvent("22222222-2222-2222-2222-222222222222"),
SimpleEvent("33333333-3333-3333-3333-333333333333"),
SimpleEvent("44444444-4444-4444-4444-444444444444"),
SimpleEvent("55555555-5555-5555-5555-555555555555"),
};
var repo = Substitute.For<IAuditLogRepository>();
// First call returns the 5 rows; subsequent calls return empty so the
// service terminates the keyset loop.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var lines = csv.Split("\r\n", StringSplitOptions.None);
// 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
Assert.Equal(7, lines.Length);
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]);
Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
Assert.Equal(string.Empty, lines[6]);
}
[Fact]
public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
var header = csv.Split("\r\n")[0];
var columns = header.Split(',');
Assert.Equal(21, columns.Length);
Assert.Equal(new[]
{
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
"CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript",
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
"PayloadTruncated", "Extra", "ForwardState",
}, columns);
}
[Fact]
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
{
// Target contains a comma → field must be wrapped in double quotes.
// Target with embedded quote → quote must be doubled ("") and field quoted.
// ResponseSummary contains CR-LF → field must be quoted.
var row = new AuditEvent
{
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a, secondary", // comma
SourceInstanceId = null,
SourceScript = "say \"hi\"", // embedded quote
Actor = null,
Target = "x",
Status = AuditStatus.Delivered,
HttpStatus = null,
DurationMs = null,
ErrorMessage = "boom\r\nthen again", // CR-LF
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
// Comma-bearing field is quoted.
Assert.Contains("\"plant-a, secondary\"", csv);
// Embedded quote is doubled inside a quoted field.
Assert.Contains("\"say \"\"hi\"\"\"", csv);
// Newline-bearing field is quoted; the inner \r\n stays as-is.
Assert.Contains("\"boom\r\nthen again\"", csv);
}
[Fact]
public async Task ExportAsync_NullField_WrittenAsEmpty()
{
// Build a row with deliberate nulls for every nullable column.
var row = new AuditEvent
{
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = null,
Status = AuditStatus.Submitted,
HttpStatus = null,
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var dataLine = csv.Split("\r\n")[1];
var fields = dataLine.Split(',');
// EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
// CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8),
// Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13),
// ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17),
// PayloadTruncated(18), Extra(19), ForwardState(20)
Assert.Equal(21, fields.Length);
Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null
Assert.Equal(string.Empty, fields[5]); // CorrelationId null
Assert.Equal(string.Empty, fields[6]); // SourceSiteId null
Assert.Equal(string.Empty, fields[12]); // HttpStatus null
Assert.Equal(string.Empty, fields[14]); // ErrorMessage null
Assert.Equal("False", fields[18]); // PayloadTruncated
Assert.Equal(string.Empty, fields[20]); // ForwardState null
}
[Fact]
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
{
// The service is asked for 3 rows but the repo would happily yield 5.
// Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
var rows = Enumerable.Range(0, 5)
.Select(i => SimpleEvent(Guid.NewGuid().ToString()))
.ToList();
var repo = Substitute.For<IAuditLogRepository>();
// Repo returns the 5 rows in a single page; the service must stop after 3.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var lines = csv.Split("\r\n", StringSplitOptions.None);
// 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
Assert.Equal(6, lines.Length);
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
}
[Fact]
public async Task ExportAsync_CancellationToken_StopsMidStream()
{
// Repo yields a single page, then on the next page call we observe the
// canceled token and throw — service should propagate OperationCanceled.
var cts = new CancellationTokenSource();
var firstPage = new List<AuditEvent>
{
SimpleEvent("11111111-1111-1111-1111-111111111111"),
SimpleEvent("22222222-2222-2222-2222-222222222222"),
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
// Cancel after delivering the first page so the next loop iteration
// sees a canceled token.
cts.Cancel();
return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
});
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
// The service writes the first page then checks the token before pulling
// the next — we expect OperationCanceledException to surface.
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
}
[Fact]
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
{
// Two pages of 2 rows each, then empty. The service must pass the last
// row of page 1 as the cursor on the page-2 call.
var p1 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
};
var p2 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
};
var pagings = new List<AuditLogPaging>();
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
// PageSize is 2 so the first page returns full and the loop continues.
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);
Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
Assert.Null(pagings[0].AfterEventId);
Assert.Null(pagings[0].AfterOccurredAtUtc);
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
}
}
@@ -0,0 +1,378 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
/// these tests pin the filter forwarding contract and the 100-row default-page-size
/// rule the grid relies on.
/// </summary>
public class AuditLogQueryServiceTests
{
private static ICentralHealthAggregator EmptyAggregator()
{
var agg = Substitute.For<ICentralHealthAggregator>();
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
return agg;
}
[Fact]
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
{
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
};
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.QueryAsync(filter, paging);
Assert.Same(expected, result);
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
}
[Fact]
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
{
var repo = Substitute.For<IAuditLogRepository>();
AuditLogPaging? observed = null;
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
Assert.NotNull(observed);
Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
Assert.Equal(100, sut.DefaultPageSize);
Assert.Null(observed.AfterOccurredAtUtc);
Assert.Null(observed.AfterEventId);
}
// ─────────────────────────────────────────────────────────────────────────
// M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
{
var repo = Substitute.For<IAuditLogRepository>();
var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var repoSnapshot = new AuditLogKpiSnapshot(
TotalEventsLastHour: 42,
ErrorEventsLastHour: 7,
BacklogTotal: 0, // repo leaves this at zero
AsOfUtc: anchor);
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(repoSnapshot));
// Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
// Sum = 16 → backlog tile shows 16.
var sites = new Dictionary<string, SiteHealthState>
{
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
};
var agg = Substitute.For<ICentralHealthAggregator>();
agg.GetAllSiteStates().Returns(sites);
var sut = new AuditLogQueryService(repo, agg);
var snapshot = await sut.GetKpiSnapshotAsync();
Assert.Equal(42, snapshot.TotalEventsLastHour);
Assert.Equal(7, snapshot.ErrorEventsLastHour);
Assert.Equal(16, snapshot.BacklogTotal);
Assert.Equal(anchor, snapshot.AsOfUtc);
// The service requests a 1-hour trailing window and lets the repo
// anchor nowUtc to its own clock — we leave the second parameter null.
await repo.Received(1).GetKpiSnapshotAsync(
TimeSpan.FromHours(1),
Arg.Is<DateTime?>(v => v == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
// plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
var sites = new Dictionary<string, SiteHealthState>
{
["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
["plant-b"] = StateWithBacklog("plant-b", pending: null),
["plant-c"] = StateWithBacklog("plant-c", pending: 4),
};
var agg = Substitute.For<ICentralHealthAggregator>();
agg.GetAllSiteStates().Returns(sites);
var sut = new AuditLogQueryService(repo, agg);
var snapshot = await sut.GetKpiSnapshotAsync();
// Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
Assert.Equal(4, snapshot.BacklogTotal);
}
// ─────────────────────────────────────────────────────────────────────────
// #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
//
// The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
// auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
// scoped Blazor-circuit ScadaBridgeDbContext. EF Core then throws
// "A second operation was started on this context instance before a previous
// operation completed." AuditLogQueryService now opens its OWN DI scope per
// QueryAsync call (scope-per-query) so it never shares the page's scoped
// context — these tests pin that contract.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
{
// A real ScadaBridgeDbContext (SQLite in-memory) registered as SCOPED with
// the real AuditLogRepository — exactly the shared-scoped-context shape
// that produces the EF race when one context services two operations.
using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
connection.Open();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ScadaBridgeDbContext>(options =>
options.UseSqlite(connection)
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
await using var provider = services.BuildServiceProvider();
// Create the schema once on a throwaway scope.
using (var seedScope = provider.CreateScope())
{
var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
await ctx.Database.EnsureCreatedAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context
// EF throws "A second operation was started on this context instance".
var t1 = sut.QueryAsync(filter);
var t2 = sut.QueryAsync(filter);
var results = await Task.WhenAll(t1, t2);
Assert.All(results, Assert.NotNull);
}
[Fact]
public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
{
// Two sequential calls must each resolve the repository from a distinct
// scope — the service must never cache a single repository instance.
var resolvedRepos = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new AuditLogQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
EmptyAggregator());
await sut.QueryAsync(new AuditLogQueryFilter());
await sut.QueryAsync(new AuditLogQueryFilter());
// Each QueryAsync opened its own scope → two distinct repo instances.
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ─────────────────────────────────────────────────────────────────────────
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
// QueryAsync's scope-per-call contract on the production path.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var expected = new List<ExecutionTreeNode>
{
new(executionId, null, 3,
new[] { "ApiOutbound" }, new[] { "Delivered" },
"plant-a", "boiler-3",
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
};
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetExecutionTreeAsync(executionId);
Assert.Same(expected, result);
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
{
// The production ctor must resolve a fresh repository per call — same
// scope-per-query contract QueryAsync upholds, so the page's auto-load
// never shares the circuit-scoped DbContext.
var resolvedRepos = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new AuditLogQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
EmptyAggregator());
await sut.GetExecutionTreeAsync(Guid.NewGuid());
await sut.GetExecutionTreeAsync(Guid.NewGuid());
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ─────────────────────────────────────────────────────────────────────────
// Task 15: SourceNode filter forwarding + distinct-nodes service contract.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
{
// The Audit Log page's new Node multi-select pushes its chip set into
// AuditLogQueryFilter.SourceNodes; the service must thread it through
// unchanged so the repository's IN-list applies.
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(
SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(filter);
await repo.Received(1).QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceNodes != null
&& f.SourceNodes.Count == 2
&& f.SourceNodes.Contains("central-a")
&& f.SourceNodes.Contains("site-plant-a-node-a")),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
{
var repo = Substitute.For<IAuditLogRepository>();
var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(expected, result);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
{
// Two back-to-back calls within the 60s TTL must hit the repository
// exactly once — the filter bar should never produce N DB hits when
// the operator opens it twice in quick succession.
var repo = Substitute.For<IAuditLogRepository>();
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a" }));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var first = await sut.GetDistinctSourceNodesAsync();
var second = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(first, second);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue
? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
: null;
var report = new SiteHealthReport(
SiteId: siteId,
SequenceNumber: 1,
ReportTimestamp: DateTimeOffset.UtcNow,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
ScriptErrorCount: 0,
AlarmEvaluationErrorCount: 0,
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
DeadLetterCount: 0,
DeployedInstanceCount: 0,
EnabledInstanceCount: 0,
DisabledInstanceCount: 0,
SiteAuditBacklog: backlog);
return new SiteHealthState
{
SiteId = siteId,
LatestReport = report,
LastReportReceivedAt = DateTimeOffset.UtcNow,
LastHeartbeatAt = DateTimeOffset.UtcNow,
LastSequenceNumber = 1,
IsOnline = true,
};
}
}
@@ -0,0 +1,534 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class AlarmTriggerConfigCodecTests
{
// ── Parse: ValueMatch ──────────────────────────────────────────────────
[Fact]
public void Parse_ValueMatch_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
Assert.False(model.NotEquals);
}
[Fact]
public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys()
{
// Older configs used "attribute" and "value" instead of the canonical names.
const string json = @"{""attribute"":""Status"",""value"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""!=Good""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.True(model.NotEquals);
Assert.Equal("Good", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_MissingMatchValue_LeavesNull()
{
const string json = @"{""attributeName"":""Status""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
}
// ── Parse: RangeViolation ──────────────────────────────────────────────
[Fact]
public void Parse_RangeViolation_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Temp"",""min"":0,""max"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_AcceptsLegacyLowHighKeys()
{
const string json = @"{""attributeName"":""Temp"",""low"":-10,""high"":50}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(-10, model.Min);
Assert.Equal(50, model.Max);
}
[Fact]
public void Parse_RangeViolation_CanonicalKeysWinOverLegacy()
{
// If both canonical and legacy aliases are present, the canonical key wins.
const string json = @"{""attributeName"":""T"",""min"":0,""low"":-999,""max"":100,""high"":999}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_StringNumericValues_AreParsed()
{
// Some configs serialize min/max as JSON strings. Codec accepts both.
const string json = @"{""attributeName"":""T"",""min"":""1.5"",""max"":""9.75""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(1.5, model.Min);
Assert.Equal(9.75, model.Max);
}
// ── Parse: RateOfChange ────────────────────────────────────────────────
[Fact]
public void Parse_RateOfChange_ReadsAllFields()
{
const string json = @"{""attributeName"":""Pressure"",""thresholdPerSecond"":25,""windowSeconds"":2,""direction"":""rising""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("Pressure", model.AttributeName);
Assert.Equal(25, model.ThresholdPerSecond);
Assert.Equal(2, model.WindowSeconds);
Assert.Equal("rising", model.Direction);
}
[Theory]
[InlineData("rising", "rising")]
[InlineData("Rising", "rising")]
[InlineData("up", "rising")]
[InlineData("positive", "rising")]
[InlineData("falling", "falling")]
[InlineData("Down", "falling")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("bogus", "either")]
[InlineData("", "either")]
public void Parse_RateOfChange_NormalizesDirectionAliases(string input, string expected)
{
var json = $@"{{""attributeName"":""x"",""direction"":""{input}""}}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(expected, model.Direction);
}
[Fact]
public void Parse_RateOfChange_MissingDirection_DefaultsToEither()
{
// Older configs predate the direction field — the codec must default it
// so existing data round-trips without surprises.
const string json = @"{""attributeName"":""x"",""thresholdPerSecond"":10,""windowSeconds"":1}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("either", model.Direction);
}
// ── Parse: misc ────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_NullOrWhitespace_ReturnsDefaultModel(string? input)
{
var model = AlarmTriggerConfigCodec.Parse(input, AlarmTriggerType.ValueMatch);
Assert.Null(model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
Assert.Equal("either", model.Direction);
}
[Fact]
public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow()
{
var model = AlarmTriggerConfigCodec.Parse("{not valid", AlarmTriggerType.RangeViolation);
Assert.Null(model.Min);
Assert.Null(model.Max);
}
// ── Serialize: ValueMatch ──────────────────────────────────────────────
[Fact]
public void Serialize_ValueMatch_WritesCanonicalKeysOnly()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Critical",
// Foreign fields from other trigger types must NOT leak into the JSON.
Min = 5,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal("Status", root.GetProperty("attributeName").GetString());
Assert.Equal("Critical", root.GetProperty("matchValue").GetString());
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString());
}
[Fact]
public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString()
{
// AlarmActor uses attributeName for subscription filtering, so the key
// must always be present even when the user hasn't picked one yet.
var model = new AlarmTriggerModel { MatchValue = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("", doc.RootElement.GetProperty("attributeName").GetString());
}
// ── Serialize: RangeViolation ──────────────────────────────────────────
[Fact]
public void Serialize_RangeViolation_WritesCanonicalNumericKeys()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = 0,
Max = 100,
MatchValue = "ignored"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(0, root.GetProperty("min").GetDouble());
Assert.Equal(100, root.GetProperty("max").GetDouble());
Assert.False(root.TryGetProperty("matchValue", out _));
}
[Fact]
public void Serialize_RangeViolation_NullBound_OmitsKey()
{
var model = new AlarmTriggerModel { AttributeName = "Temp", Min = 0, Max = null };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("min", out _));
Assert.False(doc.RootElement.TryGetProperty("max", out _));
}
// ── Serialize: RateOfChange ────────────────────────────────────────────
[Fact]
public void Serialize_RateOfChange_WritesThresholdWindowAndDirection()
{
var model = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "falling"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(25, root.GetProperty("thresholdPerSecond").GetDouble());
Assert.Equal(2, root.GetProperty("windowSeconds").GetDouble());
Assert.Equal("falling", root.GetProperty("direction").GetString());
}
[Fact]
public void Serialize_RateOfChange_AlwaysIncludesDirection()
{
// Even with a default-constructed model, the runtime needs to know how
// to evaluate — direction defaults to "either" and is always emitted.
var model = new AlarmTriggerModel { AttributeName = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
Assert.Equal("either", doc.RootElement.GetProperty("direction").GetString());
}
// ── Round-trip ─────────────────────────────────────────────────────────
[Fact]
public void RoundTrip_ValueMatch_NotEquals_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.ValueMatch);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.MatchValue, round.MatchValue);
Assert.True(round.NotEquals);
}
[Fact]
public void RoundTrip_RangeViolation_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = -10.5,
Max = 42.25
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RangeViolation);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(original.Min, round.Min);
Assert.Equal(original.Max, round.Max);
}
[Fact]
public void RoundTrip_RateOfChange_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RateOfChange);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.ThresholdPerSecond, round.ThresholdPerSecond);
Assert.Equal(original.WindowSeconds, round.WindowSeconds);
Assert.Equal(original.Direction, round.Direction);
}
// ── Parse: HiLo ────────────────────────────────────────────────────────
[Fact]
public void Parse_HiLo_ReadsAllSetpointsAndPriorities()
{
const string json = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100,""loLoPriority"":900,""loPriority"":500,""hiPriority"":500,""hiHiPriority"":900}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal("Temp", model.AttributeName);
Assert.Equal(0, model.LoLo);
Assert.Equal(10, model.Lo);
Assert.Equal(90, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Equal(900, model.LoLoPriority);
Assert.Equal(500, model.LoPriority);
Assert.Equal(500, model.HiPriority);
Assert.Equal(900, model.HiHiPriority);
}
[Fact]
public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull()
{
// Common case: only Hi/HiHi configured for over-temp protection.
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Null(model.LoLo);
Assert.Null(model.Lo);
Assert.Equal(80, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Null(model.HiPriority);
}
// ── Serialize: HiLo ────────────────────────────────────────────────────
[Fact]
public void Serialize_HiLo_OmitsNullSetpointsAndPriorities()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiHi = 100,
HiHiPriority = 900
// Lo, LoLo, and the other priorities left null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(80, root.GetProperty("hi").GetDouble());
Assert.Equal(100, root.GetProperty("hiHi").GetDouble());
Assert.Equal(900, root.GetProperty("hiHiPriority").GetInt32());
Assert.False(root.TryGetProperty("lo", out _));
Assert.False(root.TryGetProperty("loLo", out _));
Assert.False(root.TryGetProperty("hiPriority", out _));
Assert.False(root.TryGetProperty("loPriority", out _));
}
[Fact]
public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields()
{
// matchValue, min/max, threshold/window/direction must NOT show up in
// HiLo output even if the model happens to carry them.
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
MatchValue = "ignored",
Min = 1,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("matchValue", out _));
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Parse_HiLo_ReadsDeadbands()
{
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100,""hiDeadband"":2,""hiHiDeadband"":5}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(2, model.HiDeadband);
Assert.Equal(5, model.HiHiDeadband);
Assert.Null(model.LoDeadband);
Assert.Null(model.LoLoDeadband);
}
[Fact]
public void Serialize_HiLo_OmitsNullDeadbands()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiDeadband = 2
// HiHiDeadband / LoDeadband / LoLoDeadband null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
Assert.Equal(2, doc.RootElement.GetProperty("hiDeadband").GetDouble());
Assert.False(doc.RootElement.TryGetProperty("hiHiDeadband", out _));
Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _));
}
[Fact]
public void RoundTrip_HiLo_PreservesAllFields()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
LoLo = -5,
Lo = 0,
Hi = 90,
HiHi = 110,
LoLoPriority = 800,
LoPriority = 400,
HiPriority = 400,
HiHiPriority = 800,
LoLoDeadband = 1,
LoDeadband = 2,
HiDeadband = 3,
HiHiDeadband = 4
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.HiLo);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.LoLo, round.LoLo);
Assert.Equal(original.Lo, round.Lo);
Assert.Equal(original.Hi, round.Hi);
Assert.Equal(original.HiHi, round.HiHi);
Assert.Equal(original.LoLoPriority, round.LoLoPriority);
Assert.Equal(original.LoPriority, round.LoPriority);
Assert.Equal(original.HiPriority, round.HiPriority);
Assert.Equal(original.HiHiPriority, round.HiHiPriority);
Assert.Equal(original.LoLoDeadband, round.LoLoDeadband);
Assert.Equal(original.LoDeadband, round.LoDeadband);
Assert.Equal(original.HiDeadband, round.HiDeadband);
Assert.Equal(original.HiHiDeadband, round.HiHiDeadband);
}
// ── NormalizeDirection (direct) ────────────────────────────────────────
[Theory]
[InlineData("rising", "rising")]
[InlineData("RISING", "rising")]
[InlineData("falling", "falling")]
[InlineData("up", "rising")]
[InlineData("down", "falling")]
[InlineData("positive", "rising")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("", "either")]
[InlineData(null, "either")]
[InlineData("nonsense", "either")]
public void NormalizeDirection_HandlesAllAliasesAndFallsBackToEither(string? input, string expected)
{
Assert.Equal(expected, AlarmTriggerConfigCodec.NormalizeDirection(input));
}
}
@@ -0,0 +1,68 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-016. <c>DataTable</c> looped
/// <c>for i = 1..totalPages</c> and emitted one numbered <c>&lt;li&gt;</c>
/// button per page; a few thousand records at page size 25 rendered hundreds
/// of buttons into the diff on every state change. The fix windows the pager
/// so only first / last / a small range around the current page render.
/// </summary>
public class DataTablePagerTests : BunitContext
{
private IRenderedComponent<DataTable<int>> RenderTable(int itemCount, int pageSize = 25)
{
return Render<DataTable<int>>(parameters => parameters
.Add(p => p.Items, Enumerable.Range(1, itemCount).ToList())
.Add(p => p.PageSize, pageSize)
.Add(p => p.ShowSearch, false)
.Add(p => p.HeaderContent, (RenderFragment)(b => b.AddMarkupContent(0, "<th>N</th>")))
.Add(p => p.RowContent, (RenderFragment<int>)(item => b => b.AddMarkupContent(0, $"<tr><td>{item}</td></tr>"))));
}
private static int NumberedPageButtons(IRenderedComponent<DataTable<int>> cut)
=> cut.FindAll("ul.pagination li.page-item button")
.Count(b => int.TryParse(b.TextContent.Trim(), out _));
[Fact]
public void Pager_WithThousandsOfPages_RendersWindowedNotEveryPage()
{
// 5000 items / 25 = 200 pages. The pre-fix pager rendered 200 numbered
// buttons; the windowed pager renders at most a dozen.
var cut = RenderTable(itemCount: 5000);
var numbered = NumberedPageButtons(cut);
Assert.True(numbered <= 12,
$"Expected a windowed pager (<= 12 numbered buttons) but rendered {numbered}.");
}
[Fact]
public void Pager_SmallDataset_StillRendersEveryPage()
{
// 5 pages — small enough to render all numbered buttons (no windowing harm).
var cut = RenderTable(itemCount: 125);
var numbered = NumberedPageButtons(cut);
Assert.Equal(5, numbered);
}
[Fact]
public void Pager_WindowedAroundCurrentPage_AlwaysIncludesFirstAndLast()
{
var cut = RenderTable(itemCount: 5000); // 200 pages
var numbered = cut.FindAll("ul.pagination li.page-item button")
.Select(b => b.TextContent.Trim())
.Where(t => int.TryParse(t, out _))
.ToList();
// First and last page are always reachable from the windowed pager.
Assert.Contains("1", numbered);
Assert.Contains("200", numbered);
}
}
@@ -0,0 +1,117 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Characterization tests for CentralUI-015 (re-triaged Won't Fix — see
/// findings.md). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c>
/// made callers resume off the render thread; that premise is incorrect — an
/// <c>await</c> always resumes on the awaiter's own captured
/// <see cref="SynchronizationContext"/> regardless of where the awaited task
/// completes. <c>ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext</c> pins
/// that correct behaviour (it passes against both the old <c>ContinueWith</c>
/// form and the current inline-projection form). The remaining tests pin the
/// dialog result-resolution contract.
/// </summary>
public class DialogServiceThreadingTests
{
/// <summary>
/// A single-threaded sync context that records every posted callback —
/// stands in for the Blazor renderer's dispatcher.
/// </summary>
private sealed class TrackingSyncContext : SynchronizationContext
{
private readonly Thread _thread;
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
public int PostedCount;
public TrackingSyncContext()
{
_thread = new Thread(() =>
{
SetSynchronizationContext(this);
foreach (var (cb, st) in _queue.GetConsumingEnumerable())
{
cb(st);
}
}) { IsBackground = true };
_thread.Start();
}
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostedCount);
_queue.Add((d, state));
}
public void Complete() => _queue.CompleteAdding();
}
[Fact]
public async Task ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext()
{
var service = new DialogService();
var ctx = new TrackingSyncContext();
// Run the awaiting "component" code on the tracking context.
var done = new TaskCompletionSource<int>();
ctx.Post(async void (_) =>
{
try
{
var task = service.ConfirmAsync("t", "m");
// Resolve from another thread, mimicking the host dispatching.
_ = Task.Run(() => service.Resolve(true));
await task;
// The continuation after the await must be back on the tracking
// context's single thread.
done.SetResult(Environment.CurrentManagedThreadId);
}
catch (Exception ex)
{
done.SetException(ex);
}
}, null);
var resumeThreadId = await done.Task;
ctx.Complete();
// The continuation was posted to (and ran on) the captured context.
Assert.True(ctx.PostedCount >= 1,
"ConfirmAsync continuation must post back to the caller's SynchronizationContext.");
Assert.NotEqual(Environment.CurrentManagedThreadId, resumeThreadId);
}
[Fact]
public async Task ConfirmAsync_ResolvesWithExpectedValue()
{
var service = new DialogService();
var task = service.ConfirmAsync("t", "m");
service.Resolve(true);
Assert.True(await task);
}
[Fact]
public async Task PromptAsync_ResolvesWithExpectedValue()
{
var service = new DialogService();
var task = service.PromptAsync("t", "label");
service.Resolve("typed value");
Assert.Equal("typed value", await task);
}
[Fact]
public async Task PromptAsync_CancelledResolvesToNull()
{
var service = new DialogService();
var task = service.PromptAsync("t", "label");
service.Resolve(null);
Assert.Null(await task);
}
}
@@ -0,0 +1,75 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-011. <c>DiffDialog.OpenAsync</c> returns the
/// <c>TaskCompletionSource</c>'s task, completed only by <c>Close()</c>. If the
/// user navigated away while the dialog was open, <c>DisposeAsync</c> ran but
/// never completed the TCS — the awaiting caller was suspended forever and any
/// cleanup after the await was skipped. The fix completes the TCS in
/// <c>DisposeAsync</c>.
/// </summary>
public class DiffDialogTests : BunitContext
{
/// <summary>
/// DiffDialog applies/removes a body scroll-lock class and focuses the modal
/// via JS interop on open/close. Loose mode auto-completes those void calls
/// so a path that <c>await</c>s them (e.g. <c>DisposeAsync</c> →
/// <c>TryUnlockBodyAsync</c>) resumes instead of hanging on a never-completed
/// planned invocation, and no strict-mode unplanned-invocation exception
/// surfaces through the narrowed CentralUI-023 catch blocks.
/// </summary>
private void SetupBodyLockInterop()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
[Fact]
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
{
SetupBodyLockInterop();
var cut = Render<DiffDialog>();
// Open the dialog; the returned task represents the caller's await.
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
// await the dialog's own (deliberately long-lived) task.
Task<bool> pending = null!;
await cut.InvokeAsync(
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
Assert.False(pending.IsCompleted, "Dialog task should be pending while open.");
// Simulate navigating away while the dialog is still open.
await cut.InvokeAsync(async () => await cut.Instance.DisposeAsync());
// The awaiter must complete deterministically rather than hang forever.
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(pending, completed);
Assert.True(pending.IsCompletedSuccessfully);
var result = await pending;
Assert.False(result, "Dismiss-on-dispose should resolve to false (not confirmed).");
}
[Fact]
public async Task Close_CompletesPendingTaskWithTrue()
{
SetupBodyLockInterop();
var cut = Render<DiffDialog>();
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
// await the dialog's own (deliberately long-lived) task.
Task<bool> pending = null!;
await cut.InvokeAsync(
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
// Closing via the Close button completes the task with true.
await cut.InvokeAsync(() => cut.Find("button.btn-secondary").Click());
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(pending, completed);
var result = await pending;
Assert.True(result);
}
}
@@ -0,0 +1,98 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Coverage for <see cref="DurationInput"/>, the number+unit codec behind the
/// script form's "Min time between runs" field.
/// </summary>
public class DurationInputTests
{
// ── Split: TimeSpan -> (value, unit) ───────────────────────────────────
[Fact]
public void Split_Null_ReturnsBlankWithSecondsUnit()
{
var (value, unit) = DurationInput.Split(null);
Assert.Null(value);
Assert.Equal("sec", unit);
}
[Fact]
public void Split_Zero_ReturnsBlank()
{
var (value, _) = DurationInput.Split(TimeSpan.Zero);
Assert.Null(value);
}
[Fact]
public void Split_WholeMinutes_UsesMinuteUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromMinutes(5));
Assert.Equal("5", value);
Assert.Equal("min", unit);
}
[Fact]
public void Split_WholeSeconds_UsesSecondUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromSeconds(30));
Assert.Equal("30", value);
Assert.Equal("sec", unit);
}
[Fact]
public void Split_SubSecond_UsesMillisecondUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromMilliseconds(250));
Assert.Equal("250", value);
Assert.Equal("ms", unit);
}
// ── Compose: (value, unit) -> TimeSpan? ────────────────────────────────
[Fact]
public void Compose_Blank_ReturnsNull() =>
Assert.Null(DurationInput.Compose(null, "sec"));
[Fact]
public void Compose_Zero_ReturnsNull() =>
Assert.Null(DurationInput.Compose("0", "sec"));
[Fact]
public void Compose_Negative_ReturnsNull() =>
Assert.Null(DurationInput.Compose("-5", "sec"));
[Fact]
public void Compose_SecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec"));
[Fact]
public void Compose_MinutesValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min"));
[Fact]
public void Compose_MillisecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms"));
// ── Round-trip ─────────────────────────────────────────────────────────
[Theory]
[InlineData(250)]
[InlineData(30000)]
[InlineData(300000)]
public void RoundTrip_PreservesDuration(long milliseconds)
{
var original = TimeSpan.FromMilliseconds(milliseconds);
var (value, unit) = DurationInput.Split(original);
var reparsed = DurationInput.Compose(value, unit);
Assert.Equal(original, reparsed);
}
}
@@ -0,0 +1,142 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Communication;
using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.ParkedMessages;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-023. <c>DiffDialog.TryLockBodyAsync</c> /
/// <c>TryUnlockBodyAsync</c> and <c>ParkedMessages.CopyAsync</c> wrapped JS
/// interop in bare <c>catch { }</c> blocks: a genuine <see cref="JSException"/>
/// was indistinguishable from an expected <see cref="JSDisconnectedException"/>
/// and neither was logged. The fix narrows the catch and logs real interop
/// failures via <c>ILogger</c>, consistent with the CentralUI-018 fixes.
/// </summary>
public class JsInteropLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
[Fact]
public void DiffDialog_BodyLock_GenuineJsException_IsLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// The body scroll-lock runs on OnAfterRender when the dialog is shown.
// Configure that JS call to throw a genuine JSException.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSException("body lock failed"));
// Focus and any other interop is harmless here — allow it loosely.
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
cut.Render();
cut.WaitForAssertion(() =>
{
var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
Assert.Contains(warnings, e => e.Exception is JSException);
});
}
[Fact]
public void DiffDialog_BodyLock_Disconnect_IsNotLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// A circuit disconnect during the lock is expected — it must NOT log.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSDisconnectedException("circuit gone"));
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
cut.Render();
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
[Fact]
public async Task ParkedMessages_Copy_GenuineJsException_IsLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
Services.AddScoped<IDialogService, DialogService>();
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true)
.SetException(new JSException("clipboard permission denied"));
var cut = Render<ParkedMessagesPage>();
// CopyAsync is a private handler; invoke it directly with a clipboard
// call configured to fail. Pre-fix the bare catch swallowed it silently.
var copy = typeof(ParkedMessagesPage).GetMethod(
"CopyAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)copy.Invoke(cut.Instance, new object[] { "some-id" })!);
var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
Assert.Contains(warnings, e => e.Exception is JSException);
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
}
@@ -0,0 +1,77 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>MonacoEditor</c> wrapped every JS
/// interop call in a bare <c>try { ... } catch { }</c> with no logging — a
/// genuine Monaco init failure became invisible. The fix narrows the catch to
/// the expected prerender / disconnect cases and logs any real
/// <see cref="JSException"/> via <c>ILogger</c>.
/// </summary>
public class MonacoEditorLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
[Fact]
public void CreateEditor_GenuineJsException_IsLogged_NotSwallowed()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// createEditor is an InvokeVoidAsync call — configure it to throw a
// genuine JSException so we exercise the real-failure path.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
.SetException(new JSException("Monaco failed to load"));
// Pre-fix: the bare catch {} swallowed this with no trace. Post-fix:
// the component renders fine but the failure is logged.
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
var errors = provider.Entries.Where(e => e.Level == LogLevel.Error).ToList();
Assert.NotEmpty(errors);
Assert.Contains(errors, e => e.Exception is JSException);
}
[Fact]
public void CreateEditor_Prerender_DoesNotLog()
{
// When JS interop is unavailable (prerender), createEditor throws
// InvalidOperationException — that is expected and must NOT be logged.
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
.SetException(new InvalidOperationException("JS interop not available during prerender"));
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Unit tests for the <see cref="PagerWindow"/> helper introduced for
/// CentralUI-016 — windowed pagination that keeps the rendered button count
/// bounded regardless of total page count.
/// </summary>
public class PagerWindowTests
{
[Fact]
public void Build_SmallPageCount_ReturnsEveryPage_NoEllipsis()
{
var pages = PagerWindow.Build(currentPage: 3, totalPages: 5);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, pages);
}
[Fact]
public void Build_LargePageCount_IsBounded_AndIncludesFirstAndLast()
{
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
Assert.Contains(1, pages);
Assert.Contains(200, pages);
Assert.Contains(100, pages);
// First, ellipsis, window of 5, ellipsis, last — never the full 200.
Assert.True(pages.Count <= 12, $"Expected a bounded window but got {pages.Count} entries.");
}
[Fact]
public void Build_LargePageCount_InsertsEllipsisForGaps()
{
// 0 is the ellipsis sentinel.
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
Assert.Contains(0, pages);
}
[Fact]
public void Build_CurrentNearStart_NoLeadingEllipsis()
{
var pages = PagerWindow.Build(currentPage: 1, totalPages: 200);
// Pages 1..3 are contiguous from the start, so no ellipsis before them.
Assert.Equal(1, pages[0]);
Assert.NotEqual(0, pages[1]);
}
[Fact]
public void Build_ClampsOutOfRangeCurrentPage()
{
var pages = PagerWindow.Build(currentPage: 999, totalPages: 200);
Assert.Contains(200, pages);
Assert.True(pages.Count <= 12);
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Build_NonPositiveTotalPages_ReturnsEmpty(int totalPages)
{
Assert.Empty(PagerWindow.Build(currentPage: 1, totalPages: totalPages));
}
}
@@ -0,0 +1,141 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class SchemaBuilderModelTests
{
// ── Parse ─────────────────────────────────────────────────────────────────
[Fact]
public void Parse_Empty_ReturnsFallback()
{
var fallback = SchemaBuilderModel.NewObject();
Assert.Same(fallback, SchemaBuilderModel.Parse(null, fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse("", fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback));
}
[Fact]
public void Parse_Malformed_ReturnsFallback()
{
var fallback = SchemaBuilderModel.NewObject();
Assert.Same(fallback, SchemaBuilderModel.Parse("{not json", fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback));
}
[Fact]
public void Parse_ObjectSchema_ExtractsPropertiesAndRequired()
{
const string json = """
{"type":"object","properties":{
"id":{"type":"integer"},
"label":{"type":"string"},
"active":{"type":"boolean"}
},"required":["id","active"]}
""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
Assert.Equal("object", node.Type);
Assert.Collection(node.Properties,
p => { Assert.Equal("id", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
p => { Assert.Equal("label", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); },
p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); });
}
[Fact]
public void Parse_ArrayOfPrimitive_PreservesItemType()
{
var node = SchemaBuilderModel.Parse(
@"{""type"":""array"",""items"":{""type"":""integer""}}",
SchemaBuilderModel.NewValue());
Assert.Equal("array", node.Type);
Assert.NotNull(node.Items);
Assert.Equal("integer", node.Items!.Type);
}
[Fact]
public void Parse_LegacyFlatArray_TranslatedToObjectSchema()
{
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
Assert.Equal("object", node.Type);
Assert.Collection(node.Properties,
p => { Assert.Equal("x", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); });
}
[Fact]
public void Parse_NestedObjects_Recurses()
{
const string json = """
{"type":"object","properties":{
"outer":{"type":"object","properties":{
"inner":{"type":"integer"}
},"required":["inner"]}
}}
""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
var outer = Assert.Single(node.Properties);
Assert.Equal("outer", outer.Name);
Assert.Equal("object", outer.Schema.Type);
var inner = Assert.Single(outer.Schema.Properties);
Assert.Equal("inner", inner.Name);
Assert.Equal("integer", inner.Schema.Type);
Assert.True(inner.Required);
}
// ── Serialize ─────────────────────────────────────────────────────────────
[Fact]
public void Serialize_EmptyObject_OmitsRequired()
{
var node = new SchemaNode { Type = "object" };
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal("""{"type":"object","properties":{}}""", json);
}
[Fact]
public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames()
{
var node = new SchemaNode { Type = "object" };
node.Properties.Add(new SchemaProperty { Name = "id", Required = true, Schema = new SchemaNode { Type = "integer" } });
node.Properties.Add(new SchemaProperty { Name = "label", Required = false, Schema = new SchemaNode { Type = "string" } });
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal(
"""{"type":"object","properties":{"id":{"type":"integer"},"label":{"type":"string"}},"required":["id"]}""",
json);
}
[Fact]
public void Serialize_Array_IncludesItems()
{
var node = new SchemaNode { Type = "array", Items = new SchemaNode { Type = "string" } };
Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node));
}
[Fact]
public void Serialize_PropertiesWithBlankName_Skipped()
{
var node = new SchemaNode { Type = "object" };
node.Properties.Add(new SchemaProperty { Name = "", Schema = new SchemaNode { Type = "integer" } });
node.Properties.Add(new SchemaProperty { Name = "valid", Schema = new SchemaNode { Type = "string" } });
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal("""{"type":"object","properties":{"valid":{"type":"string"}},"required":["valid"]}""", json);
}
// ── Round-trip ────────────────────────────────────────────────────────────
[Fact]
public void RoundTrip_Parse_Then_Serialize_Stable()
{
const string original = """{"type":"object","properties":{"id":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}}},"required":["id"]}""";
var node = SchemaBuilderModel.Parse(original, SchemaBuilderModel.NewObject());
var roundTripped = SchemaBuilderModel.Serialize(node);
Assert.Equal(original, roundTripped);
}
}
@@ -0,0 +1,158 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Round-trip coverage for the WhileTrue/OnTrue <c>mode</c> field on the
/// Conditional and Expression script triggers.
/// </summary>
public class ScriptTriggerConfigCodecTests
{
// ── Parse: mode field ──────────────────────────────────────────────────
[Fact]
public void Parse_Conditional_WithoutMode_DefaultsToOnTrue()
{
const string json = @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
[Fact]
public void Parse_Conditional_WhileTrue_IsRead()
{
const string json =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""WhileTrue""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
[Fact]
public void Parse_Expression_WithoutMode_DefaultsToOnTrue()
{
const string json = @"{""expression"":""Attributes[\""T\""] > 1""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
[Fact]
public void Parse_Expression_WhileTrue_IsRead()
{
const string json =
@"{""expression"":""Attributes[\""T\""] > 1"",""mode"":""WhileTrue""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
[Fact]
public void Parse_UnrecognizedMode_DefaultsToOnTrue()
{
const string json =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""Sometimes""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
// ── Serialize: mode field ──────────────────────────────────────────────
[Fact]
public void Serialize_Conditional_WhileTrue_WritesMode()
{
var model = new ScriptTriggerModel
{
AttributeName = "Temp",
Operator = ">",
Threshold = 80,
Mode = ScriptTriggerMode.WhileTrue
};
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Conditional);
Assert.Contains("\"mode\":\"WhileTrue\"", json);
}
[Fact]
public void Serialize_Expression_WhileTrue_WritesMode()
{
var model = new ScriptTriggerModel
{
Expression = "Attributes[\"T\"] > 1",
Mode = ScriptTriggerMode.WhileTrue
};
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Expression);
Assert.Contains("\"mode\":\"WhileTrue\"", json);
}
// ── Round-trip ─────────────────────────────────────────────────────────
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RoundTrip_Conditional_PreservesMode(bool whileTrue)
{
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
var original = new ScriptTriggerModel
{
AttributeName = "Temp",
Operator = ">=",
Threshold = 12.5,
Mode = mode
};
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Conditional);
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(mode, reparsed.Mode);
}
// ── SupportsMinTimeBetweenRuns ─────────────────────────────────────────
[Theory]
[InlineData("ValueChange")]
[InlineData("Conditional")]
[InlineData("Expression")]
public void SupportsMinTimeBetweenRuns_TrueForAutoTriggersThatThrottle(string triggerType)
{
Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
[Theory]
[InlineData("Interval")] // has its own period control
[InlineData("Call")] // invoked explicitly — no throttle applies
[InlineData(null)] // None — never runs automatically
[InlineData("Bogus")] // Unknown trigger type
public void SupportsMinTimeBetweenRuns_FalseForIntervalCallNoneAndUnknown(string? triggerType)
{
Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RoundTrip_Expression_PreservesMode(bool whileTrue)
{
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
var original = new ScriptTriggerModel
{
Expression = "Attributes[\"T\"] > 1",
Mode = mode
};
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Expression);
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(mode, reparsed.Mode);
}
}
@@ -0,0 +1,69 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Component tests for the OnTrue/WhileTrue mode selector that
/// <see cref="ScriptTriggerEditor"/> exposes for Conditional and Expression
/// triggers.
/// </summary>
public class ScriptTriggerEditorTests : BunitContext
{
private const string ConditionalConfig =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50}";
private const string ConditionalWhileTrueConfig =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}";
[Fact]
public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
cut.Find("#script-trigger-mode").Change("WhileTrue");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
}
[Fact]
public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
// Change the threshold to force an emit without touching the mode.
cut.Find("input[type=number]").Input("75");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config);
}
[Fact]
public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalWhileTrueConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
// Editing the threshold must not silently drop the loaded WhileTrue mode.
cut.Find("input[type=number]").Input("75");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
}
}
@@ -0,0 +1,50 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for HealthMonitoring-015. A heartbeat-only registered site has
/// a <c>null</c> <c>LastReportReceivedAt</c> ("no full report yet"). The health
/// dashboard passes that value straight into <see cref="TimestampDisplay"/>, so the
/// component's <c>Value</c> must accept <c>DateTimeOffset?</c> and render a
/// <c>null</c> as a human-readable placeholder ("never") instead of the
/// <c>DateTimeOffset.MinValue</c> year-0001 sentinel. Non-null callers must keep
/// rendering the formatted timestamp exactly as before.
/// </summary>
public class TimestampDisplayTests : BunitContext
{
[Fact]
public void Render_NonNullValue_ShowsFormattedTimestamp()
{
var value = new DateTimeOffset(2026, 5, 17, 14, 30, 45, TimeSpan.Zero);
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)value)
.Add(p => p.Format, "HH:mm:ss"));
var span = cut.Find("span");
Assert.Equal(value.LocalDateTime.ToString("HH:mm:ss"), span.TextContent.Trim());
Assert.Contains("2026-05-17 14:30:45 UTC", span.GetAttribute("title")!);
}
[Fact]
public void Render_NullValue_ShowsNeverPlaceholder()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null)
.Add(p => p.Format, "HH:mm:ss"));
Assert.Contains("never", cut.Markup, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Render_NullValue_DoesNotRenderYear0001Sentinel()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null));
// The year-0001 DateTimeOffset.MinValue sentinel must never reach the UI.
Assert.DoesNotContain("0001", cut.Markup);
}
}
@@ -0,0 +1,80 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-010. <c>ToastNotification.AddToast</c>
/// scheduled <c>Task.Delay(dismissMs).ContinueWith(...)</c> with the result
/// discarded; the continuation called <c>InvokeAsync(StateHasChanged)</c>. When
/// the host page is disposed before the delay elapses, the continuation ran
/// against a disposed component and threw <c>ObjectDisposedException</c> on a
/// thread-pool thread with no catch (an unobserved task exception). The fix
/// holds a <c>CancellationTokenSource</c> cancelled in <c>Dispose()</c>.
/// </summary>
public class ToastNotificationTests : BunitContext
{
[Fact]
public async Task ShowToast_AfterDisposal_IsNoOp_AndSchedulesNothing()
{
// Regression: the pre-fix AddToast always added the toast and scheduled
// a Task.Delay continuation, even after Dispose() — the continuation
// then ran InvokeAsync(StateHasChanged) against the disposed component.
// The fix short-circuits AddToast once the disposal token is cancelled.
var cut = Render<ToastNotification>();
await cut.InvokeAsync(() => cut.Instance.Dispose());
await cut.InvokeAsync(() => cut.Instance.ShowError("after dispose", autoDismissMs: 20));
Assert.Equal(0, cut.Instance.ToastCount);
}
[Fact]
public async Task AutoDismiss_AfterDisposal_DoesNotThrowUnobservedException()
{
var unobserved = new List<Exception>();
void Handler(object? s, UnobservedTaskExceptionEventArgs e)
{
unobserved.Add(e.Exception);
e.SetObserved();
}
TaskScheduler.UnobservedTaskException += Handler;
try
{
var cut = Render<ToastNotification>();
// Auto-dismiss after a very short delay so the continuation is
// guaranteed to fire well after we dispose the component.
await cut.InvokeAsync(() => cut.Instance.ShowSuccess("hello", autoDismissMs: 20));
// Dispose the component while the auto-dismiss is still pending.
await cut.InvokeAsync(() => cut.Instance.Dispose());
// Give the (now-cancelled) auto-dismiss well past its delay.
await Task.Delay(250);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
finally
{
TaskScheduler.UnobservedTaskException -= Handler;
}
Assert.Empty(unobserved);
}
[Fact]
public async Task AutoDismiss_BeforeDisposal_StillRemovesToast()
{
var cut = Render<ToastNotification>();
await cut.InvokeAsync(() => cut.Instance.ShowInfo("transient", autoDismissMs: 20));
// The toast is visible immediately.
Assert.Contains("transient", cut.Markup);
// After the dismiss delay it is removed (auto-dismiss still works).
cut.WaitForAssertion(
() => Assert.DoesNotContain("transient", cut.Markup),
timeout: TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,62 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>TreeView</c>'s storage-restore path
/// called <c>JsonSerializer.Deserialize</c> on the raw <c>treeviewStorage</c>
/// payload outside any try block — a corrupt payload threw an uncaught
/// <c>JsonException</c> during <c>OnAfterRenderAsync</c>, breaking the
/// component. The fix guards the deserialize and ignores a corrupt payload.
/// </summary>
public class TreeViewStorageResilienceTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> Roots() => new()
{
new("a", "Alpha", new() { new("a1", "Alpha-1", new()) }),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> BuildTree()
=> Render<TreeView<TestNode>>(parameters => parameters
.Add(p => p.Items, Roots())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => b =>
b.AddMarkupContent(0, $"<span>{node.Label}</span>")))
.Add(p => p.StorageKey, "corrupt-tree"));
[Fact]
public void StorageRestore_CorruptJsonPayload_DoesNotThrow_AndStillRenders()
{
// A garbage payload that is not valid JSON for a List<string>.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{not json at all]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
// Deserialize call. Post-fix: the corrupt payload is ignored.
var cut = BuildTree();
Assert.Contains("Alpha", cut.Markup);
Assert.Contains("Beta", cut.Markup);
}
[Fact]
public void StorageRestore_WrongShapeJson_DoesNotThrow()
{
// Valid JSON, but not a List<string> — an object, not an array.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{\"unexpected\": true}");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = BuildTree();
Assert.Contains("Alpha", cut.Markup);
}
}
@@ -0,0 +1,137 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using TemplatesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for the Templates page that verify the folder/template
/// tree builds the expected DOM for the main shape categories: empty state,
/// folder-containing-template nesting, and composition leaves under their owner.
/// </summary>
public class TemplatesPageTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
public TemplatesPageTests()
{
// The page's TemplateService / TemplateFolderService are constructed via DI
// from the repository and audit service, mirroring real Host wiring.
Services.AddSingleton(_repo);
Services.AddSingleton(_audit);
Services.AddScoped<TemplateService>();
Services.AddScoped<TemplateFolderService>();
// The Templates page injects IDialogService for the new-folder prompt
// and delete confirmations. The host is rendered in MainLayout, not
// here, but the DI registration still has to satisfy the [Inject].
Services.AddScoped<IDialogService, DialogService>();
AddTestAuth();
// The TreeView inside the page persists expansion state via JS interop
// against sessionStorage (`templates-tree` key). bUnit requires explicit
// stubs for all JS interop calls, otherwise rendering throws.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
{
// The page resolves the current user via the "Username" claim in
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Design")
};
var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void Renders_EmptyState_WhenNoTemplatesOrFolders()
{
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
Assert.Contains("No templates yet", cut.Markup);
}
[Fact]
public void Renders_FolderAndTemplate_AtCorrectNesting()
{
var folder = new TemplateFolder("Dev") { Id = 1 };
var template = new Template("TestMachine") { Id = 5, FolderId = 1 };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder> { folder }));
var cut = Render<TemplatesPage>();
// The folder is rendered collapsed; assert the folder label is present,
// then expand it and assert the nested template label appears.
Assert.Contains("Dev", cut.Markup);
var folderToggle = cut.FindAll("li[role='treeitem']")
.FirstOrDefault(li => li.TextContent.Contains("Dev"))
?.QuerySelector(".tv-toggle");
Assert.NotNull(folderToggle);
folderToggle!.Click();
Assert.Contains("TestMachine", cut.Markup);
}
[Fact]
public void Renders_CompositionChildren_UnderOwningTemplate()
{
var template = new Template("TestMachine") { Id = 5 };
template.Compositions.Add(
new TemplateComposition("DelmiaReceiver") { Id = 10, ComposedTemplateId = 99 });
var composed = new Template("Other") { Id = 99 };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template, composed }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
// The owning template must be expanded for its composition leaves to be
// in the DOM — composition children only render under an expanded parent.
var ownerToggle = cut.FindAll("li[role='treeitem']")
.FirstOrDefault(li => li.TextContent.Contains("TestMachine"))
?.QuerySelector(".tv-toggle");
Assert.NotNull(ownerToggle);
ownerToggle!.Click();
Assert.Contains("DelmiaReceiver", cut.Markup);
// The composition glyph appears via Bootstrap Icons; the composed template name
// is intentionally not rendered on the tree (V7 spec).
Assert.Contains("bi-arrow-return-right", cut.Markup);
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public TestAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
@@ -0,0 +1,308 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Topology;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for the Topology page (Site → Area → Instance tree).
/// Focuses on the behavior that's specific to this page:
/// always-visible empty containers, search dimming, F2 area rename, and the
/// move-dialog destination lists.
/// </summary>
public class TopologyPageTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
public TopologyPageTests()
{
Services.AddSingleton(_repo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_deployRepo);
Services.AddSingleton(_pipeline);
Services.AddSingleton(_audit);
// DeploymentService has non-mockable concrete deps; instantiate them directly.
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
// DeploymentService gained a DiffService dependency (DeploymentManager
// contract change); register it so the page's DI graph resolves.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
// CentralUI-006: DeploymentService now also depends on the
// deployment-status notifier (a process singleton in production).
Services.AddSingleton<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier(
NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance));
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
AddTestAuth();
// The page injects IDialogService for delete confirmations; the host
// (rendered globally in MainLayout) is not present in bUnit, but the
// DI registration still has to satisfy the [Inject].
Services.AddScoped<IDialogService, DialogService>();
// Site scoping (CentralUI-002): Topology injects SiteScopeService to
// filter the tree by the user's permitted sites.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
private void SeedRepos(
IEnumerable<Site>? sites = null,
IEnumerable<Template>? templates = null,
IEnumerable<Instance>? instances = null,
Dictionary<int, IReadOnlyList<Area>>? areasBySite = null)
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
areasBySite ??= new();
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
var sid = call.Arg<int>();
return Task.FromResult(areasBySite.TryGetValue(sid, out var list)
? list
: (IReadOnlyList<Area>)new List<Area>());
});
}
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
SeedRepos();
var cut = Render<TopologyPage>();
Assert.Contains("No sites configured", cut.Markup);
}
[Fact]
public void Renders_EmptySite_AsTopLevelNode()
{
// An always-show-empty container is a hard requirement: a site with nothing
// under it must still appear so users can move/create into it.
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
var cut = Render<TopologyPage>();
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("bi-building", cut.Markup);
}
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<TopologyPage> cut, string label) =>
cut.FindAll(".tv-row")
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
?.QuerySelector(".tv-toggle");
[Fact]
public void Renders_SiteAreaInstance_Nesting()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area>
{
new("Line-1") { Id = 10, SiteId = 1, ParentAreaId = null }
}
};
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed }
},
areasBySite: areasBySite);
var cut = Render<TopologyPage>();
// Expand the site, then the area, to render the instance leaf. The
// helper scopes by the row's own label so we don't match outer rows
// whose TextContent transitively contains the inner label.
FindToggleForLabel(cut, "Plant-A")!.Click();
FindToggleForLabel(cut, "Line-1")!.Click();
Assert.Contains("Pump-001", cut.Markup);
Assert.Contains("bi-diagram-3", cut.Markup);
Assert.Contains("bi-box", cut.Markup);
Assert.Contains("NotDeployed", cut.Markup);
}
[Fact]
public void Search_DimsNonMatches_PreservesShape()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area>
{
new("Line-1") { Id = 10, SiteId = 1 },
new("Boilers") { Id = 11, SiteId = 1 }
}
};
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
areasBySite: areasBySite);
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var search = cut.Find("input[type='text']");
search.Input("Line");
// Both areas remain in the DOM (shape preserved). 'Boilers' gets the dim style.
Assert.Contains("Line-1", cut.Markup);
Assert.Contains("Boilers", cut.Markup);
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
}
[Fact]
public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
{
// Regression test for CentralUI-002. The SiteId claims issued at login were
// never read, so a Deployment user scoped to one site could view (and act
// on) every site's topology. Topology now filters the tree by the user's
// permitted sites via SiteScopeService.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("Username", "scoped-tester"),
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"),
// Permitted on site 1 only.
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
}, "TestAuth"));
// Last AuthenticationStateProvider registration wins on resolution.
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
SeedRepos(sites: new[]
{
new Site("Plant-A", "plant-a") { Id = 1 },
new Site("Plant-B", "plant-b") { Id = 2 },
});
var cut = Render<TopologyPage>();
// The permitted site is rendered; the non-permitted site is not.
Assert.Contains("Plant-A", cut.Markup);
Assert.DoesNotContain("Plant-B", cut.Markup);
}
[Fact]
public void SiteScoping_SystemWideDeploymentUser_SeesAllSites()
{
// A Deployment user with no SiteId claims is system-wide and sees every site.
SeedRepos(sites: new[]
{
new Site("Plant-A", "plant-a") { Id = 1 },
new Site("Plant-B", "plant-b") { Id = 2 },
});
var cut = Render<TopologyPage>();
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("Plant-B", cut.Markup);
}
[Fact]
public void DoubleClick_OnAreaLabel_EntersRenameMode()
{
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
{
[1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
};
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
areaLabel.DoubleClick();
// Inline rename input replaces the label.
Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
}
[Fact]
public void InstanceRows_DoNotHaveDoubleClickRename()
{
// Instance rename is out of scope; the label should not have @ondblclick wired.
// bUnit throws MissingEventHandlerException when dispatching to an element
// that has no handler — that's the assertion: the dblclick event is not bound.
SeedRepos(
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
instances: new[]
{
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
});
var cut = Render<TopologyPage>();
FindToggleForLabel(cut, "Plant-A")!.Click();
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
}
[Fact]
public void LegacyInstancesRoute_IsDeclaredOnTopologyPage()
{
// Old bookmarks to /deployment/instances must still resolve. Reflection
// check: the Topology component carries both @page directives.
var pageAttrs = typeof(TopologyPage).GetCustomAttributes(
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
.Select(a => a.Template)
.ToList();
Assert.Contains("/deployment/topology", pageAttrs);
Assert.Contains("/deployment/instances", pageAttrs);
}
}
@@ -0,0 +1,147 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit tests for TreeView's Checkbox-selection mode (T19 of the Transport
/// feature). Verifies that:
/// - a checkbox renders next to every node in Checkbox mode,
/// - clicking a folder checkbox cascades selection to every descendant leaf,
/// - clicking a leaf produces a partial parent state (Indeterminate),
/// - Single-mode behaviour is preserved (regression).
/// </summary>
public class TreeViewMultiSelectTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> TwoFoldersThreeLeaves() => new()
{
// Folder1 → Leaf1a, Leaf1b
new("f1", "Folder1", new()
{
new("l1a", "Leaf1a", new()),
new("l1b", "Leaf1b", new()),
}),
// Folder2 → Leaf2a
new("f2", "Folder2", new()
{
new("l2a", "Leaf2a", new()),
}),
};
private IRenderedComponent<TreeView<TestNode>> RenderCheckboxTree(
List<TestNode>? items = null,
HashSet<object>? selectedKeys = null,
Action<HashSet<object>>? onSelectedKeysChanged = null)
{
// Indeterminate is set via JS interop after each render. We stub it so
// bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
return Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, items ?? TwoFoldersThreeLeaves())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
})
.Add(p => p.SelectionMode, TreeViewSelectionMode.Checkbox)
.Add(p => p.InitiallyExpanded, _ => true)
.Add(p => p.SelectedKeys, selectedKeys);
if (onSelectedKeysChanged != null)
{
parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged);
}
});
}
[Fact]
public void Checkbox_mode_renders_input_checkbox_per_node()
{
var cut = RenderCheckboxTree();
// 2 folders + 3 leaves = 5 nodes, expanded → 5 checkboxes.
var checkboxes = cut.FindAll("input.tv-checkbox");
Assert.Equal(5, checkboxes.Count);
}
[Fact]
public void Clicking_folder_checkbox_selects_all_descendant_leaves()
{
HashSet<object>? captured = null;
var cut = RenderCheckboxTree(
selectedKeys: new HashSet<object>(),
onSelectedKeysChanged: keys => captured = keys);
// First checkbox is Folder1 (root #1). It has leaves l1a + l1b.
var folderCheckbox = cut.FindAll("input.tv-checkbox")[0];
folderCheckbox.Change(true);
Assert.NotNull(captured);
Assert.Equal(2, captured!.Count);
Assert.Contains((object)"l1a", captured);
Assert.Contains((object)"l1b", captured);
}
[Fact]
public void Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked()
{
// Pre-select one of Folder1's two leaves → Folder1 should compute as
// Indeterminate on the next render (carries the `tv-checkbox-indeterminate`
// CSS marker class — the JS-set `indeterminate` DOM property is set via
// interop and isn't observable through bUnit's rendered HTML).
var cut = RenderCheckboxTree(
selectedKeys: new HashSet<object> { "l1a" });
// Folder1 checkbox is the first .tv-checkbox; should carry the partial
// marker class.
var folderCheckbox = cut.FindAll("input.tv-checkbox")[0];
var classAttr = folderCheckbox.GetAttribute("class") ?? string.Empty;
Assert.Contains("tv-checkbox-indeterminate", classAttr);
// …and not the fully-checked attribute.
Assert.NotEqual("true", folderCheckbox.GetAttribute("checked"));
}
[Fact]
public void Single_mode_unchanged()
{
// Default SelectionMode is Single. No SelectedKeysChanged should fire,
// and SelectedKeyChanged (singular) should fire on content click.
object? selected = null;
HashSet<object>? bulk = null;
var cut = Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, TwoFoldersThreeLeaves())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
})
.Add(p => p.InitiallyExpanded, _ => true)
.Add(p => p.Selectable, true)
.Add(p => p.SelectedKeyChanged, (Action<object?>)(k => selected = k))
.Add(p => p.SelectedKeysChanged, (Action<HashSet<object>>)(s => bulk = s));
});
// No checkboxes rendered in Single mode.
Assert.Empty(cut.FindAll("input.tv-checkbox"));
// Clicking the content fires SelectedKeyChanged (singular) and NOT
// SelectedKeysChanged (plural).
cut.Find(".tv-content").Click();
Assert.Equal("f1", selected);
Assert.Null(bulk);
}
}
@@ -0,0 +1,676 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// bUnit tests for the TreeView component covering core rendering,
/// expand/collapse behavior, ARIA attributes, and indentation.
/// </summary>
public class TreeViewTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> SimpleRoots() => new()
{
new("a", "Alpha", new()
{
new("a1", "Alpha-1", new()),
new("a2", "Alpha-2", new()
{
new("a2x", "Alpha-2-X", new())
})
}),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> RenderTreeView(
List<TestNode>? items = null,
RenderFragment? emptyContent = null,
int indentPx = 24,
Func<TestNode, bool>? initiallyExpanded = null,
bool selectable = false,
object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null,
string? selectedCssClass = null,
string? storageKey = null,
RenderFragment<TestNode>? contextMenu = null)
{
return Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, items ?? SimpleRoots())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
})
.Add(p => p.IndentPx, indentPx)
.Add(p => p.EmptyContent, emptyContent)
.Add(p => p.InitiallyExpanded, initiallyExpanded)
.Add(p => p.Selectable, selectable)
.Add(p => p.SelectedKey, selectedKey);
if (onSelectedKeyChanged != null)
{
parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged);
}
if (selectedCssClass != null)
{
parameters.Add(p => p.SelectedCssClass, selectedCssClass);
}
if (storageKey != null)
{
parameters.Add(p => p.StorageKey, storageKey);
}
if (contextMenu != null)
{
parameters.Add(p => p.ContextMenu, contextMenu);
}
});
}
[Fact]
public void RendersRootLevelItems_WithCorrectLabels()
{
var cut = RenderTreeView();
var labels = cut.FindAll(".node-label");
// Only root-level items visible (children collapsed)
Assert.Equal(2, labels.Count);
Assert.Equal("Alpha", labels[0].TextContent);
Assert.Equal("Beta", labels[1].TextContent);
}
[Fact]
public void RendersEmptyContent_WhenItemsEmpty()
{
var cut = RenderTreeView(
items: new List<TestNode>(),
emptyContent: builder =>
{
builder.AddMarkupContent(0, "<p class=\"empty-msg\">Nothing here</p>");
});
var msg = cut.Find(".empty-msg");
Assert.Equal("Nothing here", msg.TextContent);
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("ul[role='tree']"));
}
[Fact]
public void LeafNodes_HaveNoToggle()
{
var cut = RenderTreeView();
// Beta is a leaf (index 1 in the li list)
var treeItems = cut.FindAll("li[role='treeitem']");
var betaLi = treeItems[1]; // Beta is second root
Assert.Throws<Bunit.ElementNotFoundException>(() => betaLi.QuerySelector(".tv-toggle")
?? throw new Bunit.ElementNotFoundException(".tv-toggle"));
// Should have spacer instead
Assert.NotNull(betaLi.QuerySelector(".tv-spacer"));
}
[Fact]
public void BranchNodes_ShowCollapsedToggle()
{
var cut = RenderTreeView();
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("false", alphaLi.GetAttribute("aria-expanded"));
var toggle = alphaLi.QuerySelector(".tv-toggle");
Assert.NotNull(toggle);
// V2 spec: toggle uses Bootstrap Icons chevron-right; CSS rotates on aria-expanded.
Assert.NotNull(toggle!.QuerySelector("i.bi.bi-chevron-right"));
}
[Fact]
public void CollapsedBranch_ChildrenNotInDom()
{
var cut = RenderTreeView();
// Alpha is collapsed by default, children should not be in DOM
var groups = cut.FindAll("ul[role='group']");
Assert.Empty(groups);
}
[Fact]
public void ClickToggle_ExpandsNode_ShowsChildren()
{
var cut = RenderTreeView();
// Click Alpha's toggle
var toggle = cut.Find(".tv-toggle");
toggle.Click();
// Alpha should now be expanded
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-expanded"));
// Children should appear
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void ClickExpandedToggle_Collapses_HidesChildren()
{
var cut = RenderTreeView();
// Expand Alpha
var toggle = cut.Find(".tv-toggle");
toggle.Click();
// Verify children visible
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Collapse Alpha - find the toggle again (DOM changed)
var toggleAgain = cut.Find(".tv-toggle");
toggleAgain.Click();
// Children gone
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
Assert.Empty(cut.FindAll("ul[role='group']"));
}
[Fact]
public void DeepNesting_ExpandParentThenChild_ShowsGrandchildren()
{
var cut = RenderTreeView();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Now find Alpha-2's toggle (Alpha-2 is a branch)
var toggles = cut.FindAll(".tv-toggle");
// toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha-2
Assert.True(toggles.Count >= 2);
toggles[1].Click();
// Alpha-2-X should be visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void InitiallyExpanded_ExpandsMatchingNodes()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
// Alpha and Alpha-2 should be expanded, so Alpha-2-X should be visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void RootUl_HasRoleTree()
{
var cut = RenderTreeView();
var rootUl = cut.Find("ul[role='tree']");
Assert.NotNull(rootUl);
}
[Fact]
public void NodeLi_HasRoleTreeitem()
{
var cut = RenderTreeView();
var items = cut.FindAll("li[role='treeitem']");
Assert.Equal(2, items.Count); // Two root nodes
}
[Fact]
public void ExpandedBranch_HasAriaExpandedTrue()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-expanded"));
}
[Fact]
public void ChildGroup_HasRoleGroup()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
var groups = cut.FindAll("ul[role='group']");
Assert.Single(groups);
}
[Fact]
public void Children_IndentedByIndentPxPerDepth()
{
var cut = RenderTreeView(indentPx: 30, initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
var rows = cut.FindAll(".tv-row");
// Root nodes at depth 0: padding-left: 0px
// Children at depth 1: padding-left: 30px
// Grandchildren at depth 2: padding-left: 60px
// Find Alpha row (depth 0)
var alphaRow = rows[0];
Assert.Contains("padding-left: 0px", alphaRow.GetAttribute("style"));
// Find Alpha-1 row (depth 1)
var alpha1Row = rows[1];
Assert.Contains("padding-left: 30px", alpha1Row.GetAttribute("style"));
// Find Alpha-2-X row (depth 2) - it's after Alpha-2 at index 3
var alpha2xRow = rows[3];
Assert.Contains("padding-left: 60px", alpha2xRow.GetAttribute("style"));
}
[Fact]
public void Selection_Disabled_ClickDoesNotFireCallback()
{
object? selected = null;
var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-content").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_Enabled_ClickContentFiresCallback()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-content").Click();
Assert.Equal("a", selected);
}
[Fact]
public void Selection_ClickToggle_DoesNotChangeSelection()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-toggle").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_SelectedNode_HasCssClass()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a");
var alphaRow = cut.FindAll(".tv-row")[0];
Assert.Contains("bg-primary", alphaRow.GetAttribute("class"));
}
[Fact]
public void Selection_CustomCssClass_Applied()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a", selectedCssClass: "my-highlight");
var alphaRow = cut.FindAll(".tv-row")[0];
Assert.Contains("my-highlight", alphaRow.GetAttribute("class"));
}
[Fact]
public void Selection_AriaSelected_SetOnSelectedNode()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a");
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-selected"));
}
[Fact]
public void SessionStorage_NullKey_NoJsInteropCalls()
{
var cut = RenderTreeView();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// No JS interop calls should have been made
Assert.Empty(JSInterop.Invocations);
}
[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Verify save was called
var saveInvocations = JSInterop.Invocations
.Where(i => i.Identifier == "treeviewStorage.save")
.ToList();
Assert.Single(saveInvocations);
}
[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Alpha's children should be visible because "a" was restored from storage
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
// Storage returns empty array — meaning user explicitly collapsed everything
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(
storageKey: "test-tree",
initiallyExpanded: n => n.Key == "a");
// Alpha should NOT be expanded — storage (empty) wins over InitiallyExpanded
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void ExpandAll_ExpandsAllBranches()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.ExpandAll());
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void CollapseAll_CollapsesAllBranches()
{
var cut = RenderTreeView(initiallyExpanded: _ => true);
// Verify deep content is visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
cut.InvokeAsync(() => cut.Instance.CollapseAll());
// Only roots should be visible
labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void RevealNode_ExpandsAncestors()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x"));
// Alpha-2-X should now be visible (Alpha and Alpha-2 expanded)
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x", select: true));
Assert.Equal("a2x", selected);
}
[Fact]
public void RevealNode_UnknownKey_NoOp()
{
var cut = RenderTreeView();
cut.InvokeAsync(() => cut.Instance.RevealNode("nonexistent"));
// Alpha should still be collapsed
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
}
// ── External filtering tests (R8) ──────────────────────────────────
[Fact]
public void Filtering_ReducedItems_HidesRemovedRoots()
{
var fullItems = SimpleRoots();
var cut = RenderTreeView(items: fullItems);
// Both roots visible
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
// Re-render with only Alpha (Beta removed)
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
labels = cut.FindAll(".node-label");
Assert.Single(labels);
Assert.Equal("Alpha", labels[0].TextContent);
}
[Fact]
public void Filtering_ExpansionStatePreserved()
{
var fullItems = SimpleRoots();
var cut = RenderTreeView(items: fullItems);
// Expand Alpha
cut.Find(".tv-toggle").Click();
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Re-render with only Alpha
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// Alpha-1 still visible (expansion state preserved)
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Re-render with full list again
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, fullItems)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// Alpha-1 still visible after restoration
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
}
[Fact]
public void Filtering_SelectionCleared_WhenNodeDisappears()
{
var fullItems = SimpleRoots();
object? lastSelected = "b"; // track the last value passed to callback
var cut = RenderTreeView(
items: fullItems,
selectable: true,
selectedKey: "b",
onSelectedKeyChanged: k => lastSelected = k);
// Re-render with only Alpha (Beta disappears)
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}))
.Add(p => p.Selectable, true)
.Add(p => p.SelectedKey, (object?)"b")
.Add(p => p.SelectedKeyChanged, (Action<object?>)(k => lastSelected = k));
});
// SelectedKeyChanged should have been called with null
Assert.Null(lastSelected);
}
// ── Context menu tests ──────────────────────────────────────────────
[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
var cut = RenderTreeView();
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// No dropdown-menu should appear
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickShowsMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Dropdown menu should contain the button for Alpha
var menu = cut.Find(".dropdown-menu");
Assert.NotNull(menu);
var btn = menu.QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha", btn!.TextContent);
}
[Fact]
public void ContextMenu_EscapeKeyDismissesMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
Assert.NotNull(cut.Find(".dropdown-menu"));
// Press Escape on the menu — R15 spec requires it to dismiss.
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" });
Assert.Empty(cut.FindAll(".dropdown-menu"));
}
[Fact]
public void ContextMenu_NonEscapeKey_DoesNotDismiss()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" });
Assert.NotEmpty(cut.FindAll(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
var cut = RenderTreeView(
initiallyExpanded: n => n.Key == "a",
contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var rows = cut.FindAll(".tv-row");
rows[0].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Now right-click Alpha-1
rows = cut.FindAll(".tv-row");
rows[1].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 150, ClientY = 250 });
// Should be only one dropdown-menu, showing Alpha-1
var menus = cut.FindAll(".dropdown-menu");
Assert.Single(menus);
var btn = menus[0].QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha-1", btn!.TextContent);
}
}
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// <summary>
/// Basic compilation and type-existence tests for Phase 4-6 UI pages.
/// Full rendering tests would require bUnit or WebApplicationFactory (Phase 8).
/// These verify pages compile and key types are accessible.
/// </summary>
public class UnitTest1
{
[Fact]
public void CentralUI_Assembly_IsLoadable()
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
Assert.NotNull(assembly);
Assert.Equal("ZB.MOM.WW.ScadaBridge.CentralUI", assembly.GetName().Name);
}
[Fact]
public void Pages_AllExist_InAssembly()
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
var types = assembly.GetTypes();
// Verify all Phase 4-6 page types exist by checking they compiled
var pageRoutes = new[]
{
"Admin_Sites",
"Admin_DataConnections",
"Admin_Areas",
"Admin_ApiKeys",
"Admin_LdapMappings",
"Monitoring_Health",
"Monitoring_EventLogs",
"Monitoring_ParkedMessages",
"Monitoring_AuditLog",
"Deployment_Instances",
"Deployment_Deployments",
"Deployment_DebugView",
"Design_Templates",
"Design_SharedScripts",
"Design_ExternalSystems",
};
// Pages compile into types named like Components_Pages_Admin_Sites
// We can't check exact names since Razor generates them, but we can verify the assembly has many types
Assert.True(types.Length > 15, $"Expected many types in CentralUI assembly, got {types.Length}");
}
[Fact]
public void SharedComponents_Exist_InAssembly()
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
var typeNames = assembly.GetTypes().Select(t => t.Name).ToHashSet();
// Shared components should compile into types
Assert.True(assembly.GetTypes().Length > 0);
}
[Fact]
public void ServiceCollectionExtensions_AddCentralUI_IsCallable()
{
// Verify the extension method exists and is callable
var method = typeof(ServiceCollectionExtensions).GetMethod("AddCentralUI");
Assert.NotNull(method);
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj" />
<!--
The DbContext-race regression test (AuditLogQueryServiceTests) exercises a
real ScadaBridgeDbContext + AuditLogRepository over SQLite in-memory to prove
scope-per-query isolation. Pulls in the ConfigurationDatabase project.
-->
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
</ItemGroup>
</Project>