test(playwright): Site Calls keyset pagination edge case (Wave 4)

This commit is contained in:
Joseph Doherty
2026-06-07 04:06:08 -04:00
parent f5535ad5c1
commit 3b71ac220a
@@ -491,4 +491,89 @@ public class SiteCallsPageTests
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`). It is the only
// text-muted small span in the table footer, so a scoped GetByText
// regex is unambiguous.
var pageIndicator = page.Locator("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();
}
finally
{
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(prefix);
}
}
}