using Microsoft.Playwright; using Xunit; 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. /// RetryClickThrough — clicking Retry on a Parked row confirms /// the dialog, relays to the owning site, and surfaces an outcome toast. /// /// /// /// /// The DB-seeding tests are + Skip.IfNot: /// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), /// matching the established ScadaLink.ConfigurationDatabase.Tests idiom. /// /// [Collection("Playwright")] public class SiteCallsPageTests { private const string SiteCallsUrl = "/site-calls/report"; private readonly PlaywrightFixture _fixture; public SiteCallsPageTests(PlaywrightFixture fixture) { _fixture = fixture; } /// /// Sets the Target-keyword search box and commits the value to the server /// as its own discrete circuit message before the caller clicks Query. /// /// The #sc-search input is a Blazor @bind /// (commit-on-change): only fires /// input events, and the change that actually updates /// _targetFilter on the server fires on blur. The original test /// relied on the Query ClickAsync itself to blur the field — that /// makes the change (blur) and the click a single, near- /// simultaneous gesture and races them over the SignalR circuit: when the /// click is processed before the change has updated /// _targetFilter, Search() runs with a stale (empty) keyword /// and the grid returns unfiltered rows. /// /// /// raises the change as a /// fully-awaited action of its own, so its circuit message is enqueued and /// sent before the later Query ClickAsync's message. The SignalR /// connection delivers messages in send order and the Blazor circuit /// processes them sequentially, so _targetFilter is guaranteed /// committed before Search() runs — the two are no longer one /// racing gesture. /// /// private static async Task SetSearchKeywordAsync(IPage page, string keyword) { var search = page.Locator("#sc-search"); await search.FillAsync(keyword); // Commit the @bind as a discrete change event — not a blur side effect // of the subsequent Query click. await search.DispatchEventAsync("change"); } [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(); } /// Skip reason shared by the DB-seeding tests when MSSQL is down. private const string DbUnavailableSkipReason = "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."; [SkippableFact] public async Task FilterNarrowing_ChannelFilterShrinksGrid() { Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); 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 SetSearchKeywordAsync(page, 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. The // grid filters with an exact Target match, so the db row must be // absent — use the retrying ToHaveCount assertion so the negative // check waits out the post-query re-render rather than reading a // point-in-time count. await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0); // Now filter by Channel = DbOutbound with the db target — the row flips. await SetSearchKeywordAsync(page, 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(); await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0); } finally { await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); } } [SkippableFact] public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog() { Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); 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 SetSearchKeywordAsync(page, 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); } } [SkippableFact] public async Task RetryDiscard_VisibleOnlyOnParkedRows() { Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); 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 SetSearchKeywordAsync(page, 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 SetSearchKeywordAsync(page, 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); } } [SkippableFact] public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast() { Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var runId = Guid.NewGuid().ToString("N"); var targetPrefix = $"playwright-test/sc-retry-click/{runId}/"; var parkedId = Guid.NewGuid(); var now = DateTime.UtcNow; try { // A single Parked row — the only status from which Retry/Discard can // be relayed to the owning site. Unlike the display-only tests above, // this one actually relays to the owning site, so the SourceSite must // be a *real* site identifier from the running cluster (site-a) and // not the cosmetic "plant-a" label: an unknown site has no registered // ClusterClient, so CentralCommunicationActor drops the envelope // without replying and the relay only resolves on the 10s inner Ask // timeout — too slow for the toast assertion below. Relayed to a live // site, the site finds no parked S&F message for this freshly-seeded // GUID and replies a fast NotParked ack, which still surfaces a toast. await SiteCallDataSeeder.InsertSiteCallAsync( trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked", sourceSite: "site-a", status: "Parked", retryCount: 3, lastError: "HTTP 503 from ERP", httpStatus: 503, createdAtUtc: now, updatedAtUtc: now); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await SetSearchKeywordAsync(page, 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(); // Click Retry — this opens the confirmation dialog (DialogHost modal). await parkedRow.Locator("button:has-text('Retry')").ClickAsync(); // Confirm the relay in the dialog footer ("Confirm" — the non-danger // label; Discard would render "Delete"). var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')"); await Assertions.Expect(confirmButton).ToBeVisibleAsync(); await confirmButton.ClickAsync(); // The relay outcome surfaces on a toast — Applied, NotParked or, if // the owning site is offline in this environment, SiteUnreachable. // We only assert that an outcome toast appears (exactly one — the // single-toast contract), not which one, since the live cluster // state determines the outcome. The wait is generous (15s): the // relay round-trips to the site over ClusterClient, and a worst-case // path can sit on the 10s inner relay timeout before the response — // and the toast itself auto-dismisses 5s after it appears, so the // assertion must catch it inside that window. var toast = page.Locator(".toast"); await Assertions.Expect(toast).ToBeVisibleAsync( new() { Timeout = 15_000 }); Assert.Equal(1, await toast.CountAsync()); } finally { await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); } } }