test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4)
This commit is contained in:
+148
@@ -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 < 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 > 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user