feat(centralui): Site Calls page with Retry/Discard and Audit drill-in

This commit is contained in:
Joseph Doherty
2026-05-21 04:51:14 -04:00
parent 3cf2b4d47e
commit 7e9d74697b
6 changed files with 1435 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
using Microsoft.Data.SqlClient;
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
/// <summary>
/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests
/// (Site Call Audit #22, follow-ups Task 6).
///
/// <para>
/// The Site Calls page reads the central <c>SiteCalls</c> table through the
/// <c>SiteCallAuditActor</c>, which is a pure read-from-table mirror — so a row
/// INSERTed directly into <c>SiteCalls</c> surfaces on the page exactly as a
/// telemetry-ingested row would. Mirrors <see cref="Audit.AuditDataSeeder"/>:
/// each test inserts its own rows at setup and best-effort deletes them at
/// teardown, keeping the suite self-contained without touching
/// <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test name
/// + a GUID so the teardown <c>DELETE</c> never touches rows the cluster itself
/// produced. <c>CreatedAtUtc</c>/<c>UpdatedAtUtc</c> are pinned to "now" so the
/// page's default (unconstrained) query window sees the row.
/// </para>
/// </summary>
internal static class SiteCallDataSeeder
{
private const string DefaultConnectionString =
"Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
/// <summary>
/// Connection string for the running cluster's configuration DB. Resolved
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// dev defaults.
/// </summary>
public static string ConnectionString
{
get
{
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
}
}
/// <summary>
/// Inserts a single row into the central <c>SiteCalls</c> table. Optional
/// fields are nullable so a test can shape the row to the status/channel it
/// needs for its grid assertions. <c>TrackedOperationId</c> is stored as the
/// 36-character GUID string the entity mapping expects.
/// </summary>
public static async Task InsertSiteCallAsync(
Guid trackedOperationId,
string channel,
string target,
string sourceSite,
string status,
int retryCount,
DateTime createdAtUtc,
DateTime updatedAtUtc,
string? lastError = null,
int? httpStatus = null,
DateTime? terminalAtUtc = null,
CancellationToken ct = default)
{
const string sql = @"
INSERT INTO [SiteCalls]
([TrackedOperationId], [Channel], [Target], [SourceSite], [Status], [RetryCount],
[LastError], [HttpStatus], [CreatedAtUtc], [UpdatedAtUtc], [TerminalAtUtc], [IngestedAtUtc])
VALUES
(@id, @channel, @target, @sourceSite, @status, @retryCount,
@lastError, @httpStatus, @createdAtUtc, @updatedAtUtc, @terminalAtUtc, SYSUTCDATETIME());";
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("@id", trackedOperationId.ToString());
cmd.Parameters.AddWithValue("@channel", channel);
cmd.Parameters.AddWithValue("@target", target);
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
cmd.Parameters.AddWithValue("@status", status);
cmd.Parameters.AddWithValue("@retryCount", retryCount);
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("@createdAtUtc", createdAtUtc);
cmd.Parameters.AddWithValue("@updatedAtUtc", updatedAtUtc);
cmd.Parameters.AddWithValue("@terminalAtUtc", (object?)terminalAtUtc ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>
/// Best-effort cleanup. Deletes every <c>SiteCalls</c> row whose <c>Target</c>
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — the
/// prefix carries a per-run GUID so the rows are unique to this test run.
/// </summary>
public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM [SiteCalls] WHERE [Target] LIKE @prefix";
cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%");
await cmd.ExecuteNonQueryAsync(ct);
}
catch
{
// Best-effort — the prefix carries a GUID so the rows are unique to
// this test run and won't collide on the next pass.
}
}
/// <summary>
/// Probe whether the configuration DB is reachable. Tests gate their per-test
/// setup on this so a downed cluster surfaces a clear message rather than an
/// opaque <see cref="SqlException"/>.
/// </summary>
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
try
{
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync(ct);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,224 @@
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
/// <summary>
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
/// follow-ups Task 6).
///
/// <para>
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
/// exercises the UI through Playwright, then best-effort deletes the rows by
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
/// table through the <c>SiteCallAuditActor</c> (a pure read-from-table mirror),
/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row
/// would — the same seeding model the Audit Log E2E tests use. The pattern
/// keeps each test self-contained without touching
/// <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Scenarios covered (per the Task 6 brief):
/// <list type="bullet">
/// <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
/// <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
/// <item><c>DrillIn</c> — the "View audit history" link deep-links into the
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
/// rows, never on Failed (or other) rows.</item>
/// </list>
/// </para>
/// </summary>
[Collection("Playwright")]
public class SiteCallsPageTests
{
private const string SiteCallsUrl = "/site-calls/report";
private readonly PlaywrightFixture _fixture;
public SiteCallsPageTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task PageLoads_ForDeploymentUser()
{
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains(SiteCallsUrl, page.Url);
await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync();
// The filter card's Query button is the page's primary action.
await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
}
[Fact]
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException(
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-filter/{runId}/";
var apiId = Guid.NewGuid();
var dbId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// One ApiOutbound row, one DbOutbound row — distinct Targets.
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Unfiltered query: both seeded rows appear (the Target keyword scopes
// to this run so unrelated cluster rows do not interfere).
await page.Locator("#sc-search").FillAsync(targetPrefix + "api");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Only the ApiOutbound row matches the exact target keyword.
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}db").CountAsync());
// Now filter by Channel = DbOutbound with the db target — the row flips.
await page.Locator("#sc-search").FillAsync(targetPrefix + "db");
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}api").CountAsync());
}
finally
{
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
var trackedId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "endpoint");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The row carries a "View audit history" link whose href is the
// canonical correlationId deep-link — the TrackedOperationId IS the
// audit CorrelationId.
var link = page.Locator($"a[data-test='audit-link-{trackedId}']");
await Assertions.Expect(link).ToBeVisibleAsync();
var href = await link.GetAttributeAsync("href");
Assert.Equal($"/audit/log?correlationId={trackedId}", href);
// Following the link lands on the Audit Log page with the query-string
// drill-in context intact.
await link.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"correlationId={trackedId}", page.Url);
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
}
finally
{
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task RetryDiscard_VisibleOnlyOnParkedRows()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-actions/{runId}/";
var parkedId = Guid.NewGuid();
var failedId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// One Parked row (actionable) and one Failed row (terminal — not
// actionable from central).
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
sourceSite: "plant-a", status: "Parked", retryCount: 3,
lastError: "HTTP 503 from ERP", httpStatus: 503,
createdAtUtc: now, updatedAtUtc: now);
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
sourceSite: "plant-a", status: "Failed", retryCount: 1,
lastError: "constraint violation",
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Query the parked row first.
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
// The Parked row exposes both Retry and Discard.
await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync();
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
// Now the Failed row — Retry/Discard are absent.
await page.Locator("#sc-search").FillAsync(targetPrefix + "failed");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" });
await Assertions.Expect(failedRow).ToBeVisibleAsync();
Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync());
Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync());
}
finally
{
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
}

View File

@@ -0,0 +1,377 @@
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);
});
}
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);
}
}