From 7e9d74697b756a343e4da589b632e6792fb89f6f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 04:51:14 -0400 Subject: [PATCH] feat(centralui): Site Calls page with Retry/Discard and Audit drill-in --- .../Components/Layout/NavMenu.razor | 13 + .../Pages/SiteCalls/SiteCallsReport.razor | 317 +++++++++++++++ .../Pages/SiteCalls/SiteCallsReport.razor.cs | 369 +++++++++++++++++ .../SiteCalls/SiteCallDataSeeder.cs | 135 +++++++ .../SiteCalls/SiteCallsPageTests.cs | 224 +++++++++++ .../Pages/SiteCallsReportPageTests.cs | 377 ++++++++++++++++++ 6 files changed, 1435 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs create mode 100644 tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs create mode 100644 tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 1c05b7e..5dc07e7 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -91,6 +91,19 @@ + @* Site Calls — Site Call Audit (#22). Deployment-role only, + matching the Notification Report page's gate; the section + header sits inside the policy block so a non-Deployment + user does not see the heading. *@ + + + + + + + @* Monitoring — Health Dashboard is all-roles; Event Logs and Parked Messages are Deployment-role only (Component-CentralUI). *@ diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor new file mode 100644 index 0000000..019b316 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -0,0 +1,317 @@ +@page "/site-calls/report" +@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Messages.Audit +@using ScadaLink.Communication +@inject CommunicationService CommunicationService +@inject ISiteRepository SiteRepository +@inject IDialogService Dialog +@inject ILogger Logger + +
+ + +
+

Site Calls

+ +
+ + @* ── Filters ── *@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ + @if (_listError != null) + { +
@_listError
+ } + + @* ── Site call list ── *@ + @if (_siteCalls == null) + { + @if (_loading) + { +
Loading…
+ } + } + else if (_siteCalls.Count == 0) + { +
+
+
No site calls
+
No cached calls match the current filters.
+
+
+ } + else + { +
+ + + + + + + + + + + + + + + + + @foreach (var c in _siteCalls) + { + + + + + + + + + + + + + } + +
Tracked operationSource siteChannelTargetStatusRetriesLast errorCreatedUpdatedActions
@ShortId(c.TrackedOperationId)@SiteName(c.SourceSite)@c.Channel@c.Target + @c.Status + @if (c.IsStuck) + { + Stuck + } + @c.RetryCount + @if (!string.IsNullOrEmpty(c.LastError)) + { +
@c.LastError
+ } + else + { + + } +
+ @* The TrackedOperationId is the audit CorrelationId, so the + link deep-links into the central Audit Log pre-filtered to + this cached call's lifecycle events. *@ + + View audit history + + @* Retry/Discard relay only on Parked rows — central relays the + action to the owning site; Failed and other statuses are not + actionable from central. *@ + @if (c.Status == "Parked") + { + + + } +
+
+ + @* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id) + cursor rather than page numbers, so we keep a stack of cursors to step + backwards and the response's NextAfter* cursor to step forwards. *@ +
+ + Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows + +
+ + +
+
+ } +
+ +@* ── Row detail modal ── *@ +@if (_detailSiteCall != null) +{ + var d = _detailSiteCall; + +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs new file mode 100644 index 0000000..2a37606 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -0,0 +1,369 @@ +using Microsoft.Extensions.Logging; +using ScadaLink.CentralUI.Components.Shared; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.CentralUI.Components.Pages.SiteCalls; + +/// +/// Code-behind for the central Site Calls report page (Site Call Audit #22). A +/// near-mirror of : +/// it queries the central SiteCalls table via +/// , +/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard +/// of Parked cached calls to their owning site. +/// +/// +/// Unlike the Notification report, the query response uses a (CreatedAtUtc DESC, +/// TrackedOperationId DESC) keyset cursor rather than page numbers, so paging +/// keeps a stack of the cursors that opened each page (to step backwards) plus the +/// response's NextAfter* cursor (to step forwards). +/// +/// +/// +/// Retry/Discard relay to the owning site has a distinct +/// outcome — central is an eventually-consistent mirror, not the source of truth, so +/// a relay that never reaches the site is a transient transport condition, surfaced +/// to the operator differently from a generic failure. +/// +/// +public partial class SiteCallsReport +{ + private const int PageSize = 50; + + private ToastNotification _toast = default!; + private List _sites = new(); + + // List + private List? _siteCalls; + private bool _loading; + private string? _listError; + private bool _actionInProgress; + + // Keyset paging. The first page is opened with the empty (null, null) cursor. + // _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty + // on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor + // is the cursor for the following page, echoed back by the last query. + private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new(); + private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null); + private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor; + + // Row detail modal + private SiteCallSummary? _detailSiteCall; + private SiteCallDetail? _detail; + private bool _detailLoading; + private string? _detailError; + + // Filters + private string _statusFilter = string.Empty; + private string _channelFilter = string.Empty; + private string _siteFilter = string.Empty; + private string _targetFilter = string.Empty; + private bool _stuckOnly; + private DateTime? _fromFilter; + private DateTime? _toFilter; + + private bool HasNextPage => _nextCursor is not null; + + protected override async Task OnInitializedAsync() + { + try + { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + } + catch (Exception ex) + { + // Non-fatal — the source-site filter just falls back to raw site IDs. + Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter."); + } + + await RefreshAll(); + } + + /// Re-fetch the current page (Refresh button, and after a relay action). + private async Task RefreshAll() + { + await FetchPage(_currentCursor); + } + + /// Apply the filters and start again from the first page. + private async Task Search() + { + _cursorStack.Clear(); + await FetchPage((null, null)); + } + + private async Task PrevPage() + { + if (_cursorStack.Count == 0) + { + return; + } + + // The top of the stack is the cursor of the page BEFORE the current one. + var previousCursor = _cursorStack.Pop(); + await FetchPage(previousCursor); + } + + private async Task NextPage() + { + if (_nextCursor is not { } next) + { + return; + } + + // Stepping forward: remember the current page's cursor so Previous can + // return to it. + _cursorStack.Push(_currentCursor); + await FetchPage(next); + } + + /// + /// Fetch one keyset page starting after . + /// + private async Task FetchPage( + (DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor) + { + _loading = true; + _listError = null; + try + { + var request = new SiteCallQueryRequest( + CorrelationId: Guid.NewGuid().ToString("N"), + StatusFilter: NullIfEmpty(_statusFilter), + SourceSiteFilter: NullIfEmpty(_siteFilter), + ChannelFilter: NullIfEmpty(_channelFilter), + TargetKeyword: NullIfEmpty(_targetFilter), + StuckOnly: _stuckOnly, + FromUtc: ToUtc(_fromFilter), + ToUtc: ToUtc(_toFilter), + AfterCreatedAtUtc: cursor.AfterCreatedAtUtc, + AfterId: cursor.AfterId, + PageSize: PageSize); + + var response = await CommunicationService.QuerySiteCallsAsync(request); + if (response.Success) + { + _siteCalls = response.SiteCalls.ToList(); + _currentCursor = cursor; + + // The response echoes the last row's cursor. A short page (fewer + // rows than requested) has no further page even if a cursor came + // back, so gate Next on a full page too. + _nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated + && response.NextAfterId is { } nextId + && _siteCalls.Count == PageSize + ? (nextCreated, nextId) + : null; + } + else + { + _listError = response.ErrorMessage ?? "Query failed."; + } + } + catch (Exception ex) + { + _listError = $"Query failed: {ex.Message}"; + } + _loading = false; + } + + private async Task RetrySiteCall(SiteCallSummary c) + { + var confirmed = await Dialog.ConfirmAsync( + "Retry cached call", + $"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + + $"to site {SiteName(c.SourceSite)}?"); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var response = await CommunicationService.RetrySiteCallAsync( + new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite)); + ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage, + appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}."); + if (response.Success) + { + await RefreshAll(); + } + } + catch (Exception ex) + { + _toast.ShowError($"Retry failed: {ex.Message}"); + } + _actionInProgress = false; + } + + private async Task DiscardSiteCall(SiteCallSummary c) + { + var confirmed = await Dialog.ConfirmAsync( + "Discard cached call", + $"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + + $"to site {SiteName(c.SourceSite)}? This cannot be undone.", + danger: true); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var response = await CommunicationService.DiscardSiteCallAsync( + new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite)); + ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage, + appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}."); + if (response.Success) + { + await RefreshAll(); + } + } + catch (Exception ex) + { + _toast.ShowError($"Discard failed: {ex.Message}"); + } + _actionInProgress = false; + } + + /// + /// Surface a relay outcome on the toast. The + /// case is deliberately distinct from a generic failure — the action was not + /// applied but the operator can retry once the site is back online. + /// + private void ShowRelayOutcome( + SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage) + { + switch (outcome) + { + case SiteCallRelayOutcome.Applied: + _toast.ShowSuccess(appliedMessage); + break; + case SiteCallRelayOutcome.NotParked: + _toast.ShowInfo(errorMessage + ?? "The site reported nothing to do — the cached call is no longer parked."); + break; + case SiteCallRelayOutcome.SiteUnreachable: + _toast.ShowError(errorMessage + ?? "Site unreachable — the relay did not reach the owning site. " + + "Try again once the site is back online."); + break; + case SiteCallRelayOutcome.OperationFailed: + default: + _toast.ShowError(errorMessage ?? "The site could not apply the action."); + break; + } + + // Defensive: a non-Applied/non-Unreachable outcome that somehow reports an + // unreachable site still gets the unreachable wording. + if (outcome != SiteCallRelayOutcome.SiteUnreachable && !siteReachable + && outcome != SiteCallRelayOutcome.Applied) + { + _toast.ShowError("Site unreachable — the relay did not reach the owning site."); + } + } + + private async Task ShowDetail(SiteCallSummary c) + { + // The summary fields render immediately from the grid row; the full detail + // (HttpStatus, all timestamps, LastError) fills in once the fetch completes. + _detailSiteCall = c; + _detail = null; + _detailError = null; + _detailLoading = true; + StateHasChanged(); + + try + { + var response = await CommunicationService.GetSiteCallDetailAsync( + new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId)); + if (response.Success && response.Detail != null) + { + _detail = response.Detail; + } + else + { + _detailError = response.ErrorMessage ?? "Failed to load site call detail."; + } + } + catch (Exception ex) + { + _detailError = $"Failed to load site call detail: {ex.Message}"; + } + _detailLoading = false; + } + + private void CloseDetail() + { + _detailSiteCall = null; + _detail = null; + _detailError = null; + _detailLoading = false; + } + + private async Task RetryFromDetail(SiteCallSummary c) + { + await RetrySiteCall(c); + // RefreshAll replaces the row list; close the modal so the user sees the + // refreshed grid rather than a now-stale detail snapshot. + CloseDetail(); + } + + private async Task DiscardFromDetail(SiteCallSummary c) + { + await DiscardSiteCall(c); + CloseDetail(); + } + + private void ClearFilters() + { + _statusFilter = string.Empty; + _channelFilter = string.Empty; + _siteFilter = string.Empty; + _targetFilter = string.Empty; + _stuckOnly = false; + _fromFilter = null; + _toFilter = null; + } + + private bool HasActiveFilters => + !string.IsNullOrEmpty(_statusFilter) || + !string.IsNullOrEmpty(_channelFilter) || + !string.IsNullOrEmpty(_siteFilter) || + !string.IsNullOrEmpty(_targetFilter) || + _stuckOnly || + _fromFilter != null || + _toFilter != null; + + private string SiteName(string siteId) => + _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; + + private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + /// + /// The filter inputs are UTC wall-clock — stamp + /// on the local-typed value so the query is unambiguous. + /// + private static DateTime? ToUtc(DateTime? value) => + value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc); + + /// + /// The SiteCalls timestamps are UTC ; wrap them as + /// a for TimestampDisplay. + /// + private static DateTimeOffset? AsOffset(DateTime? value) => + value == null + ? null + : new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)); + + private static string ShortId(Guid id) => id.ToString("N")[..12]; + + private static string StatusBadgeClass(string status) => status switch + { + "Delivered" => "bg-success", + "Parked" => "bg-danger", + "Failed" => "bg-danger", + "Attempted" => "bg-warning text-dark", + "Forwarded" => "bg-info text-dark", + "Submitted" => "bg-info text-dark", + "Discarded" => "bg-secondary", + _ => "bg-light text-dark" + }; +} diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs new file mode 100644 index 0000000..b05d3f0 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs @@ -0,0 +1,135 @@ +using Microsoft.Data.SqlClient; + +namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls; + +/// +/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests +/// (Site Call Audit #22, follow-ups Task 6). +/// +/// +/// The Site Calls page reads the central SiteCalls table through the +/// SiteCallAuditActor, which is a pure read-from-table mirror — so a row +/// INSERTed directly into SiteCalls surfaces on the page exactly as a +/// telemetry-ingested row would. Mirrors : +/// each test inserts its own rows at setup and best-effort deletes them at +/// teardown, keeping the suite self-contained without touching +/// infra/mssql/seed-config.sql. +/// +/// +/// +/// Rows are tagged with a unique Target prefix derived from the test name +/// + a GUID so the teardown DELETE never touches rows the cluster itself +/// produced. CreatedAtUtc/UpdatedAtUtc are pinned to "now" so the +/// page's default (unconstrained) query window sees the row. +/// +/// +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"; + + /// + /// Connection string for the running cluster's configuration DB. Resolved + /// from SCADALINK_PLAYWRIGHT_DB when set, otherwise the local docker + /// dev defaults. + /// + public static string ConnectionString + { + get + { + var fromEnv = Environment.GetEnvironmentVariable(EnvVar); + return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv; + } + } + + /// + /// Inserts a single row into the central SiteCalls table. Optional + /// fields are nullable so a test can shape the row to the status/channel it + /// needs for its grid assertions. TrackedOperationId is stored as the + /// 36-character GUID string the entity mapping expects. + /// + 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); + } + + /// + /// Best-effort cleanup. Deletes every SiteCalls row whose Target + /// starts with . Swallows all errors — the + /// prefix carries a per-run GUID so the rows are unique to this test run. + /// + 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. + } + } + + /// + /// 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 . + /// + public static async Task IsAvailableAsync(CancellationToken ct = default) + { + try + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(ct); + return true; + } + catch + { + return false; + } + } +} diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs new file mode 100644 index 0000000..6a6c6e9 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs @@ -0,0 +1,224 @@ +using Microsoft.Playwright; + +namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls; + +/// +/// End-to-end coverage for the central Site Calls page (Site Call Audit #22, +/// follow-ups Task 6). +/// +/// +/// Each test seeds its own SiteCalls rows directly into the running +/// cluster's configuration database via , +/// exercises the UI through Playwright, then best-effort deletes the rows by +/// their Target prefix. The Site Calls page reads the SiteCalls +/// table through the SiteCallAuditActor (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 +/// infra/mssql/seed-config.sql. +/// +/// +/// +/// Scenarios covered (per the Task 6 brief): +/// +/// PageLoads — the page renders for a Deployment-role user. +/// FilterNarrowing — a channel filter narrows the results grid. +/// DrillIn — the "View audit history" link deep-links into the +/// Audit Log pre-filtered to the call's TrackedOperationId. +/// RetryDiscardVisibility — Retry/Discard appear only on Parked +/// rows, never on Failed (or other) rows. +/// +/// +/// +[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); + } + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs new file mode 100644 index 0000000..b665cc4 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -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; + +/// +/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22). +/// +/// Testability note: is a concrete class with +/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all +/// route through an injected (the Site Call Audit proxy), +/// so the tests wire a real, lightweight with a scripted +/// that replies with fixed responses — the same seam +/// SetSiteCallAudit exists for. Mirrors . +/// +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 + { + 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 _queryRequests = new(); + private readonly List _retryRequests = new(); + private readonly List _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.Instance); + + var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); + _comms.SetSiteCallAudit(auditProxy); + + 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("Username", "tester"), + new Claim(ClaimTypes.Role, "Deployment"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + [Fact] + public void Page_RequiresDeploymentPolicy() + { + var attr = typeof(SiteCallsReportPage) + .GetCustomAttributes(typeof(AuthorizeAttribute), true) + .Cast() + .FirstOrDefault(); + + Assert.NotNull(attr); + Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); + } + + [Fact] + public void Renders_SiteCallRows() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("ERP.GetOrder", cut.Markup); + Assert.Contains("Historian.Write", cut.Markup); + }); + } + + [Fact] + public void StuckRow_IsBadged() + { + var cut = Render(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(), null, null); + + var cut = Render(); + + 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(); + + 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(); + 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(); + 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(); + 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); + } + + /// + /// Stand-in for the Site Call Audit actor. Replies to each message type with + /// the test's currently-scripted response. + /// + private sealed class ScriptedSiteCallAuditActor : ReceiveActor + { + public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test) + { + Receive(r => + { + test._queryRequests.Add(r); + Sender.Tell(test._queryReply); + }); + Receive(r => + { + test._retryRequests.Add(r); + Sender.Tell(test._retryReply); + }); + Receive(r => + { + test._discardRequests.Add(r); + Sender.Tell(test._discardReply); + }); + } + } + + /// 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) + => Task.FromResult(true); + + public Task PromptAsync( + string title, string label, string initialValue = "", string? placeholder = null) + => Task.FromResult(null); + } +}