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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
+381
@@ -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);
|
||||
}
|
||||
}
|
||||
+320
@@ -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><select data-test="filter-channel-select"></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-<dim>-ms-opt-<value>"</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);
|
||||
}
|
||||
}
|
||||
+380
@@ -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);
|
||||
}
|
||||
}
|
||||
+299
@@ -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);
|
||||
}
|
||||
}
|
||||
+177
@@ -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 > 0, warning
|
||||
/// on Stuck > 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>());
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+70
@@ -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><input type="datetime-local"></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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+292
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+106
@@ -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"":{}}"));
|
||||
}
|
||||
}
|
||||
+86
@@ -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());
|
||||
}
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
+589
@@ -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><li></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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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>
|
||||
Reference in New Issue
Block a user