225 lines
10 KiB
C#
225 lines
10 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|