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;
///
/// bUnit rendering tests for the Notification Report page.
///
/// Testability note: is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The report calls all
/// route through an injected (the notification-outbox
/// proxy), so the tests wire a real, lightweight with a
/// scripted that replies with fixed responses — the
/// same seam SetNotificationOutbox exists for.
///
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
{
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 _queryRequests = new();
private readonly List _retryRequests = new();
private readonly List _discardRequests = new();
public NotificationReportPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
Services.AddSingleton(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For();
siteRepo.GetAllSitesAsync(Arg.Any())
.Returns(Task.FromResult>(new List
{
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(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();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(NotificationReportPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void Renders_NotificationRows()
{
var cut = Render();
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();
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();
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();
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(), TotalCount: 0);
var cut = Render();
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();
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 — 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 markup.)
var cut = Render();
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();
nav.NavigateTo($"/notifications/report?correlationId={guid:D}");
var cut = Render();
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
{
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();
nav.NavigateTo($"/notifications/report?correlationId={id}");
var cut = Render();
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();
nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111");
var cut = Render();
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();
nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111");
var cut = Render();
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);
}
///
/// Stand-in for the notification-outbox actor. Replies to each outbox message
/// type with the test's currently-scripted response.
///
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationReportPageTests test)
{
Receive(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(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(r =>
{
test._retryRequests.Add(r);
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
});
Receive(r =>
{
test._discardRequests.Add(r);
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
});
}
}
/// A dialog service that auto-confirms, so action paths run end-to-end.
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task 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 ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
public Task PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult(null);
public Task ShowAsync(
string title,
Microsoft.AspNetCore.Components.RenderFragment> body,
string? size = null)
=> Task.FromResult(default);
}
}