586 lines
30 KiB
C#
586 lines
30 KiB
C#
using Microsoft.Playwright;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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>
|
|
/// <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
|
|
/// the dialog, relays to the owning site, and surfaces an outcome toast.</item>
|
|
/// <item><c>DiscardClickThrough</c> — clicking Discard on a Parked row confirms
|
|
/// the danger dialog ("Delete"), relays to the owning site, and surfaces an
|
|
/// outcome toast.</item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
|
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
|
/// matching the established <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c> idiom.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class SiteCallsPageTests
|
|
{
|
|
private const string SiteCallsUrl = "/site-calls/report";
|
|
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public SiteCallsPageTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the Target-keyword search box and commits the value to the server
|
|
/// as its own discrete circuit message before the caller clicks Query.
|
|
/// <para>
|
|
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
|
|
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
|
|
/// <c>input</c> events, and the <c>change</c> that actually updates
|
|
/// <c>_targetFilter</c> on the server fires on blur. The original test
|
|
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
|
|
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
|
|
/// simultaneous gesture and races them over the SignalR circuit: when the
|
|
/// <c>click</c> is processed before the <c>change</c> has updated
|
|
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
|
|
/// and the grid returns unfiltered rows.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
|
|
/// fully-awaited action of its own, so its circuit message is enqueued and
|
|
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
|
|
/// connection delivers messages in send order and the Blazor circuit
|
|
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
|
|
/// committed before <c>Search()</c> runs — the two are no longer one
|
|
/// racing gesture.
|
|
/// </para>
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
|
private const string DbUnavailableSkipReason =
|
|
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
|
"or set SCADABRIDGE_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: "site-a", status: "Delivered", retryCount: 0,
|
|
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
|
|
sourceSite: "site-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: "site-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: "site-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: "site-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.
|
|
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(
|
|
1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors <see cref="RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast"/>
|
|
/// but exercises the Discard path: clicking Discard opens a <em>danger</em>
|
|
/// confirm modal (Dialog.ConfirmAsync with <c>danger: true</c>), whose footer
|
|
/// button is labelled "Delete" (not "Confirm"). Confirming relays a discard to
|
|
/// the owning site and <c>ShowRelayOutcome</c> surfaces exactly one outcome toast.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task DiscardClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/sc-discard-click/{runId}/";
|
|
var parkedId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
// Parked + real site-a so the discard relay resolves fast (NotParked ack for this
|
|
// freshly-seeded GUID), surfacing 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();
|
|
|
|
// Discard opens the danger confirm modal.
|
|
await parkedRow.Locator("button:has-text('Discard')").ClickAsync();
|
|
|
|
// Danger confirm — labelled "Delete" (Dialog.ConfirmAsync(..., danger: true)).
|
|
var deleteButton = page.Locator(".modal-footer .btn-danger");
|
|
await Assertions.Expect(deleteButton).ToBeVisibleAsync();
|
|
await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
|
|
await deleteButton.ClickAsync();
|
|
|
|
// One outcome toast (Applied / NotParked / SiteUnreachable — tolerant).
|
|
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Status dropdown filters the grid to the selected lifecycle status.
|
|
/// Seeds two rows sharing a per-run Target prefix — one <c>Parked</c>, one
|
|
/// <c>Delivered</c> — then filters status=Parked: the Parked marker row
|
|
/// surfaces (with a <c>Parked</c> status badge) while the Delivered marker
|
|
/// row is excluded even though both share the prefix. This mirrors
|
|
/// <see cref="FilterNarrowing_ChannelFilterShrinksGrid"/> but exercises the
|
|
/// STATUS axis rather than the channel axis.
|
|
/// <para>
|
|
/// <c>SourceSite</c> is <c>site-a</c> (a permitted site) on both rows —
|
|
/// <c>FilterPermittedAsync</c> drops rows whose source site is not permitted.
|
|
/// </para>
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task StatusFilter_NarrowsToSelectedStatus()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/wave4-sc/{runId}/";
|
|
var parkedId = Guid.NewGuid();
|
|
var deliveredId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
// Two rows sharing the prefix: one Parked, one Delivered. site-a is a
|
|
// permitted source site (FilterPermittedAsync keeps it).
|
|
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);
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: deliveredId, channel: "ApiOutbound", target: targetPrefix + "delivered",
|
|
sourceSite: "site-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);
|
|
|
|
// Filter status=Parked and scope to the exact Parked marker — the row
|
|
// surfaces and its status badge reads Parked.
|
|
await page.Locator("#sc-status").SelectOptionAsync("Parked");
|
|
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
|
await page.ClickAsync("[data-test='site-calls-query']");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
|
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
|
await Assertions.Expect(parkedRow.Locator("span.badge:has-text('Parked')")).ToBeVisibleAsync();
|
|
|
|
// Same status=Parked filter, now searching the Delivered marker: the
|
|
// Delivered row is excluded by the status filter, so no row carries
|
|
// its marker. The retrying ToHaveCount waits out the re-render.
|
|
await SetSearchKeywordAsync(page, targetPrefix + "delivered");
|
|
await page.ClickAsync("[data-test='site-calls-query']");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Assertions.Expect(
|
|
page.Locator("tbody tr").Filter(new() { HasText = targetPrefix + "delivered" }))
|
|
.ToHaveCountAsync(0);
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the filters match no rows the grid renders the empty-state card
|
|
/// rather than a table. A per-run GUID Target is searched (exact match), so
|
|
/// nothing can match — guaranteed empty without seeding. Asserts both the
|
|
/// absence of data rows and the empty-state literal.
|
|
/// <para>
|
|
/// The empty-state literal lives in <c>div.card > div.card-body…</c>, but
|
|
/// the filter card also uses <c>.card-body</c>, so a bare <c>.card-body</c>
|
|
/// locator is ambiguous under strict mode. We assert the literal via
|
|
/// <see cref="IPage.GetByText(string,PageGetByTextOptions)"/> instead.
|
|
/// </para>
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task EmptyState_NoMatch_ShowsEmptyCard()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// A per-run GUID target matches nothing (exact match → guaranteed empty).
|
|
await SetSearchKeywordAsync(page, $"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none");
|
|
await page.ClickAsync("[data-test='site-calls-query']");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// No data rows, and the empty-state literal renders. GetByText avoids the
|
|
// strict-mode ambiguity of the shared .card-body class.
|
|
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(0);
|
|
await Assertions.Expect(
|
|
page.GetByText("No cached calls match the current filters."))
|
|
.ToBeVisibleAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keyset pagination traverses forward (Next) and back (Previous) across a
|
|
/// full page boundary. The grid pages at 50 rows ordered
|
|
/// <c>CreatedAtUtc DESC, TrackedOperationId DESC</c>; the Next button is
|
|
/// enabled only when the current page came back exactly full (50 rows), so a
|
|
/// short page (1 row) is the last page.
|
|
/// <para>
|
|
/// <c>Target</c> is an exact match, so seeding 51 rows that all share ONE
|
|
/// identical target string lets a single <c>#sc-search</c> keyword select all
|
|
/// 51 → page 1 = 50 rows, page 2 = 1 row. Staggering <c>createdAtUtc</c> by
|
|
/// the loop index makes the keyset order strict and deterministic. Every row
|
|
/// uses the permitted source site <c>site-a</c> so
|
|
/// <c>FilterPermittedAsync</c> keeps them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Web-first only: each page-transition assertion checks the row COUNT first
|
|
/// (which waits for the keyset fetch to render) BEFORE the pager button
|
|
/// states, because the <c>_loading</c> flag also disables both buttons
|
|
/// mid-fetch — reading button state before the fetch settles would race.
|
|
/// </para>
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task Pagination_KeysetNextAndPrev_TraversesPages()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var prefix = $"playwright-test/wave4-scpage/{runId}/";
|
|
// All 51 rows share ONE identical exact target — a single keyword selects
|
|
// the whole set, which then spans the 50-row page boundary.
|
|
var sharedTarget = prefix + "row";
|
|
|
|
try
|
|
{
|
|
// 51 rows, identical target, distinct TrackedOperationId, all site-a /
|
|
// Delivered, timestamps staggered by index so the keyset order
|
|
// (CreatedAtUtc DESC, TrackedOperationId DESC) is strict.
|
|
var now = DateTime.UtcNow;
|
|
for (int i = 0; i < 51; i++)
|
|
{
|
|
var ts = now.AddSeconds(-i);
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: Guid.NewGuid(), channel: "ApiOutbound", target: sharedTarget,
|
|
sourceSite: "site-a", status: "Delivered", retryCount: 0,
|
|
createdAtUtc: ts, updatedAtUtc: ts);
|
|
}
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await SetSearchKeywordAsync(page, sharedTarget);
|
|
await page.ClickAsync("[data-test='site-calls-query']");
|
|
|
|
// The pager indicator span (`Page {N} · {rows} rows`). Scope the
|
|
// locator to the pager wrapper div
|
|
// (`.d-flex.justify-content-between.align-items-center`) so a future
|
|
// second `span.text-muted.small` elsewhere on the page can't make
|
|
// this match ambiguous under strict mode.
|
|
var pageIndicator = page.Locator(
|
|
".d-flex.justify-content-between.align-items-center span.text-muted.small");
|
|
|
|
// ── Page 1: full page (50 rows). Assert COUNT first (waits for the
|
|
// fetch), then the indicator and the button states. ──
|
|
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
|
|
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeDisabledAsync();
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeEnabledAsync();
|
|
|
|
// ── Next → Page 2: short page (1 row). Last page, so Next disables. ──
|
|
await page.ClickAsync("[data-test='site-calls-next']");
|
|
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1);
|
|
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 2");
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeEnabledAsync();
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeDisabledAsync();
|
|
|
|
// ── Previous → back on Page 1: full page again, Prev disables. ──
|
|
await page.ClickAsync("[data-test='site-calls-prev']");
|
|
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
|
|
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeDisabledAsync();
|
|
// Page 1 is full again on the back-leg, so Next must re-enable —
|
|
// catches a regression that left Next disabled after a page-back.
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeEnabledAsync();
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(prefix);
|
|
}
|
|
}
|
|
}
|