From 99a69c1fbaf4ad7c6c9b6df827b1c97f8bda5d8a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 04:18:04 -0400 Subject: [PATCH] test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4) --- .../Notifications/NotificationActionTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs index b3b93b7e..b416d59c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs @@ -289,4 +289,152 @@ public class NotificationActionTests await NotificationDataSeeder.DeleteByMarkerAsync(marker); } } + + /// + /// The "Stuck only" filter narrows the result set to genuinely stuck rows. A + /// notification IsStuck iff its Status ∈ {Pending, Retrying} AND its + /// CreatedAt < now − StuckAgeThreshold (default 10 min). Two rows share one + /// ListName marker: a genuinely-stuck row (Retrying, back-dated 15 min for + /// margin) and a non-stuck Parked@now row. Querying by the marker alone surfaces + /// BOTH; toggling Stuck-only ON must drop the fresh row and keep only the stuck one. + /// This uses the POSITIVE form (stuck row present, fresh row gone): the default + /// StuckAgeThreshold is 10 min — verified in NotificationOutboxOptions and the + /// docker cluster carries no override — so a Retrying row back-dated 15 min passes both + /// the repository's StuckOnly SQL predicate and the page's IsStuck derivation. + /// + [SkippableFact] + public async Task StuckOnlyFilter_NarrowsToStuckRows() + { + Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var marker = $"zztest-notif-wave4-{runId}"; + + // Genuinely-stuck row: Retrying + back-dated 15 min (> the 10-min StuckAgeThreshold). + await NotificationDataSeeder.InsertNotificationAsync( + Guid.NewGuid(), marker, "wave4-stuck", + status: "Retrying", createdAt: DateTimeOffset.UtcNow.AddMinutes(-15), + sourceSite: "site-a"); + // Non-stuck row: Parked @ now (terminal-ish status + fresh timestamp → not stuck). + await NotificationDataSeeder.InsertParkedNotificationAsync( + Guid.NewGuid(), marker, "wave4-fresh", "site-a"); + + try + { + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync(); + + // Narrow to the pair by the exact ListName marker (commit the @bind value with a + // change dispatch — FillAsync only fires `input`), then Query. + await page.Locator("#no-list").FillAsync(marker); + await page.Locator("#no-list").DispatchEventAsync("change"); + await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The marker alone selects BOTH rows (proves the stuck filter is what narrows, + // not the marker). + await Assertions.Expect( + page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" })) + .ToHaveCountAsync(1, new() { Timeout = 15_000 }); + await Assertions.Expect( + page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" })) + .ToHaveCountAsync(1); + + // Toggle Stuck-only ON and re-Query. + await page.Locator("#no-stuck-only").CheckAsync(); + await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Only the stuck row survives: fresh gone, stuck present. + await Assertions.Expect( + page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" })) + .ToHaveCountAsync(0, new() { Timeout = 15_000 }); + var stuckRow = page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" }); + await Assertions.Expect(stuckRow).ToHaveCountAsync(1); + + // And the surviving row carries the stuck badge (positive corroboration that the + // page classifies it as stuck, not merely that the SQL filter kept it). + await Assertions.Expect( + stuckRow.Locator("span.badge.bg-warning.text-dark:has-text('Stuck')")) + .ToBeVisibleAsync(); + } + finally + { + await NotificationDataSeeder.DeleteByMarkerAsync(marker); + } + } + + /// + /// Page-number pagination: the pager renders only when _totalCount > 50 + /// (page size 50). Seeding 51 rows under one ListName marker yields exactly two + /// pages (50 + 1). The test drives the page-NUMBER pager forward (Next) and back + /// (Previous), asserting the row count, the page indicator, and the Previous/Next + /// enabled/disabled states at each step. Per the harness contract the row COUNT is + /// asserted FIRST at each step — it waits out the fetch, during which _loading + /// disables both pager buttons — so the button-state assertions never race the in-flight + /// query. + /// + [SkippableFact] + public async Task Pagination_PageNumberNextAndPrev_TraversesPages() + { + Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var marker = $"zztest-notif-wave4-{runId}"; + + // 51 Parked rows under one marker → 2 pages (50 + 1); the pager renders (> 50). + for (int i = 0; i < 51; i++) + { + await NotificationDataSeeder.InsertParkedNotificationAsync( + Guid.NewGuid(), marker, $"wave4-page-{i:D2}", "site-a"); + } + + try + { + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync(); + + await page.Locator("#no-list").FillAsync(marker); + await page.Locator("#no-list").DispatchEventAsync("change"); + await page.Locator("button.btn-primary:has-text('Query')").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Pager locators. Previous/Next are scoped by their text (the page header's + // Refresh button is also a .btn-outline-secondary.btn-sm). The indicator span is + // the pager's only `span.text-muted.small` once rows render — the "Loading…" + // placeholder that shares that class renders only while `_notifications == null`. + var prev = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')"); + var next = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')"); + var indicator = page.Locator("span.text-muted.small"); + + // ── Page 1 ── (count first — it waits out the fetch — then indicator + buttons). + await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 }); + await Assertions.Expect(indicator).ToContainTextAsync("Page 1"); + await Assertions.Expect(prev).ToBeDisabledAsync(); + await Assertions.Expect(next).ToBeEnabledAsync(); + + // ── Forward to page 2 ── + await next.ClickAsync(); + await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1, new() { Timeout = 15_000 }); + await Assertions.Expect(indicator).ToContainTextAsync("Page 2"); + await Assertions.Expect(prev).ToBeEnabledAsync(); + await Assertions.Expect(next).ToBeDisabledAsync(); + + // ── Back to page 1 ── + await prev.ClickAsync(); + await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 }); + await Assertions.Expect(indicator).ToContainTextAsync("Page 1"); + await Assertions.Expect(prev).ToBeDisabledAsync(); + } + finally + { + await NotificationDataSeeder.DeleteByMarkerAsync(marker); + } + } }