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; } [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 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); } } [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 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); } } [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 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); } } [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. 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); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); 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(); // 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. var toast = page.Locator(".toast"); await Assertions.Expect(toast).ToBeVisibleAsync(); Assert.Equal(1, await toast.CountAsync()); } finally { await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); } } }