460 lines
19 KiB
C#
460 lines
19 KiB
C#
using System.Security.Claims;
|
|
using Akka.Actor;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ScadaLink.CentralUI.Components.Shared;
|
|
using ScadaLink.Commons.Entities.Sites;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Messages.Audit;
|
|
using ScadaLink.Communication;
|
|
using ScadaLink.Security;
|
|
using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
|
|
|
|
namespace ScadaLink.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();
|
|
}
|
|
|
|
[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"));
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|