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); } } }