420 lines
18 KiB
C#
420 lines
18 KiB
C#
using System.Security.Claims;
|
||
using Akka.Actor;
|
||
using Bunit;
|
||
using Bunit.TestDoubles;
|
||
using Microsoft.AspNetCore.Components;
|
||
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 every outbox query the page issued (filter assertions) and the
|
||
// most recent retry/discard requests the actor received.
|
||
private readonly List<NotificationOutboxQueryRequest> _queryRequests = new();
|
||
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(JwtTokenService.UsernameClaimType, "tester"),
|
||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||
};
|
||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||
Services.AddAuthorizationCore();
|
||
Services.AddTestConfigurationMutations();
|
||
// 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);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Flow Debugger follow-ups (register row 111, plan D2): ?correlationId=
|
||
// seeds a read-only Notification pill filter, rides the query as
|
||
// NotificationIdFilter, and auto-opens the detail when exactly one row
|
||
// with that id comes back.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void NotificationReport_CorrelationIdQuery_DashedGuid_IsNormalisedToTheStoredNForm()
|
||
{
|
||
// Notify.Send mints NotificationId as Guid "N"; the Flow Debugger / Audit Log hold the
|
||
// same value as a Guid and may render it "D". Either spelling must filter to the row.
|
||
var guid = Guid.Parse("a72bc17f-b681-43eb-a364-e136c54d51c0");
|
||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||
nav.NavigateTo($"/notifications/report?correlationId={guid:D}");
|
||
|
||
var cut = Render<NotificationReportPage>();
|
||
|
||
cut.WaitForAssertion(() =>
|
||
{
|
||
Assert.Single(_queryRequests);
|
||
Assert.Equal(guid.ToString("N"), _queryRequests[0].NotificationIdFilter);
|
||
Assert.Contains(guid.ToString("N")[..12], cut.Find("[data-test='notif-filter-notification']").TextContent);
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void NotificationReport_CorrelationIdQuery_SeedsTheNotificationFilterAndAutoOpensDetail()
|
||
{
|
||
const string id = "notif-aaaaaaaa-1111";
|
||
_queryReply = new NotificationOutboxQueryResponse(
|
||
"q", true, null, new List<NotificationSummary>
|
||
{
|
||
new(id, "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),
|
||
}, TotalCount: 1);
|
||
|
||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||
nav.NavigateTo($"/notifications/report?correlationId={id}");
|
||
|
||
var cut = Render<NotificationReportPage>();
|
||
|
||
cut.WaitForAssertion(() =>
|
||
{
|
||
// The very first fetch already carries the exact-match filter.
|
||
Assert.Single(_queryRequests);
|
||
Assert.Equal(id, _queryRequests[0].NotificationIdFilter);
|
||
|
||
// The read-only pill names the notification by its short id.
|
||
var pill = cut.Find("[data-test='notif-filter-notification']");
|
||
Assert.Contains("Notification", pill.TextContent);
|
||
Assert.Contains(id[..12], pill.TextContent);
|
||
|
||
// Exactly one matching row came back → the detail modal is open on it.
|
||
var modal = cut.Find(".modal.show");
|
||
Assert.Contains("Pump fault at Plant-A", modal.TextContent);
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void ClearFilters_DropsTheNotificationFilter()
|
||
{
|
||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||
nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111");
|
||
|
||
var cut = Render<NotificationReportPage>();
|
||
cut.WaitForState(() => cut.FindAll("[data-test='notif-filter-notification']").Count == 1);
|
||
|
||
// The pill alone makes the filter set "active" — Clear is enabled.
|
||
var clear = cut.FindAll("button").First(b => b.TextContent.Trim() == "Clear");
|
||
Assert.False(clear.HasAttribute("disabled"));
|
||
|
||
clear.Click();
|
||
|
||
cut.WaitForAssertion(() =>
|
||
{
|
||
Assert.Empty(cut.FindAll("[data-test='notif-filter-notification']"));
|
||
var clearAfter = cut.FindAll("button").First(b => b.TextContent.Trim() == "Clear");
|
||
Assert.True(clearAfter.HasAttribute("disabled"));
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public void NotificationFilterPill_Clear_DropsTheFilterAndRequeries()
|
||
{
|
||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||
nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111");
|
||
|
||
var cut = Render<NotificationReportPage>();
|
||
cut.WaitForState(() => cut.FindAll("[data-test='notif-filter-notification-clear']").Count == 1);
|
||
|
||
cut.Find("[data-test='notif-filter-notification-clear']").Click();
|
||
|
||
cut.WaitForAssertion(() =>
|
||
{
|
||
Assert.Empty(cut.FindAll("[data-test='notif-filter-notification']"));
|
||
// The × re-runs the query without the id filter.
|
||
Assert.Equal(2, _queryRequests.Count);
|
||
Assert.Null(_queryRequests[1].NotificationIdFilter);
|
||
});
|
||
}
|
||
|
||
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>(req =>
|
||
{
|
||
test._queryRequests.Add(req);
|
||
Sender.Tell(test._queryReply with { CorrelationId = req.CorrelationId });
|
||
});
|
||
// The ?correlationId= auto-open path fetches the row's full detail;
|
||
// reply with a minimal detail so the modal settles instead of the
|
||
// Ask timing out.
|
||
Receive<NotificationDetailRequest>(r => Sender.Tell(new NotificationDetailResponse(
|
||
r.CorrelationId, true, null,
|
||
new NotificationDetail(
|
||
r.NotificationId, "Email", "Ops On-Call", "Pump fault at Plant-A",
|
||
"Body", "Parked", RetryCount: 3, LastError: "SMTP timeout",
|
||
ResolvedTargets: null, TypeData: null, SourceSiteId: "plant-a",
|
||
SourceInstanceId: "Pump-001", SourceScript: null,
|
||
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
|
||
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||
LastAttemptAt: null, NextAttemptAt: null, DeliveredAt: null))));
|
||
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, string? confirmLabel = null)
|
||
=> Task.FromResult(true);
|
||
|
||
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
|
||
// regardless of severity — it exists to let action paths run end to end, not to
|
||
// assert anything about styling.
|
||
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
|
||
=> Task.FromResult(true);
|
||
|
||
public Task<string?> PromptAsync(
|
||
string title, string label, string initialValue = "", string? placeholder = null)
|
||
=> Task.FromResult<string?>(null);
|
||
|
||
public Task<TResult?> ShowAsync<TResult>(
|
||
string title,
|
||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||
string? size = null)
|
||
=> Task.FromResult<TResult?>(default);
|
||
}
|
||
}
|