From 3b71ac220a5473f4b1845d616228bf7529a90fc6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 04:06:08 -0400 Subject: [PATCH] test(playwright): Site Calls keyset pagination edge case (Wave 4) --- .../SiteCalls/SiteCallsPageTests.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs index 3677d7c7..2b1f7c2b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs @@ -491,4 +491,89 @@ public class SiteCallsPageTests page.GetByText("No cached calls match the current filters.")) .ToBeVisibleAsync(); } + + /// + /// Keyset pagination traverses forward (Next) and back (Previous) across a + /// full page boundary. The grid pages at 50 rows ordered + /// CreatedAtUtc DESC, TrackedOperationId DESC; 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. + /// + /// Target is an exact match, so seeding 51 rows that all share ONE + /// identical target string lets a single #sc-search keyword select all + /// 51 → page 1 = 50 rows, page 2 = 1 row. Staggering createdAtUtc by + /// the loop index makes the keyset order strict and deterministic. Every row + /// uses the permitted source site site-a so + /// FilterPermittedAsync keeps them. + /// + /// + /// 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 _loading flag also disables both buttons + /// mid-fetch — reading button state before the fetch settles would race. + /// + /// + [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); + } + } }