test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4)

This commit is contained in:
Joseph Doherty
2026-06-07 04:18:04 -04:00
parent 5774b30d0d
commit 99a69c1fba
@@ -289,4 +289,152 @@ public class NotificationActionTests
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
/// <summary>
/// The "Stuck only" filter narrows the result set to genuinely stuck rows. A
/// notification IsStuck iff its <c>Status ∈ {Pending, Retrying}</c> AND its
/// <c>CreatedAt &lt; now StuckAgeThreshold</c> (default 10 min). Two rows share one
/// <c>ListName</c> marker: a genuinely-stuck row (<c>Retrying</c>, back-dated 15 min for
/// margin) and a non-stuck <c>Parked@now</c> 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
/// <c>StuckAgeThreshold</c> 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 <c>IsStuck</c> derivation.
/// </summary>
[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);
}
}
/// <summary>
/// Page-number pagination: the pager renders only when <c>_totalCount &gt; 50</c>
/// (page size 50). Seeding 51 rows under one <c>ListName</c> 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 <c>_loading</c>
/// disables both pager buttons — so the button-state assertions never race the in-flight
/// query.
/// </summary>
[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);
}
}
}