Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs
T

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