Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
T

443 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Playwright;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
/// <summary>
/// End-to-end coverage for the central Notification Report page's Retry / Discard
/// actions on parked notifications (Notification Outbox #21).
///
/// <para>
/// Each test seeds its own <c>Parked</c> row directly into the running cluster's
/// configuration database via <see cref="NotificationDataSeeder"/>, exercises the UI
/// through Playwright, then best-effort deletes the row by its unique <c>ListName</c>
/// marker. The Notification Report page reads the <c>Notifications</c> table through the
/// <c>NotificationOutboxActor</c> singleton — its query path is a pure read-from-table
/// projection (no default time window), so a directly-INSERTed row surfaces exactly as a
/// site-ingested row would. Crucially, the actor's manual Retry / Discard handlers act
/// purely on the central row (load by id → flip <c>Status</c> → persist) with NO site
/// relay, so a directly-seeded Parked row is genuinely retryable / discardable from
/// central and the action's success toast (<c>ToastNotification.ShowSuccess</c>) appears.
/// This is therefore a real mutating action test, not merely a render guard.
/// </para>
///
/// <para>
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), matching
/// the established <c>SiteCallsPageTests</c> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class NotificationActionTests
{
private const string NotificationReportUrl = "/notifications/report";
private readonly PlaywrightFixture _fixture;
public NotificationActionTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"NotificationDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
/// <summary>
/// Commits a <c>@bind</c> (commit-on-<c>change</c>) form control to the server as its
/// own discrete circuit message before the caller clicks Query. Same rationale as
/// <c>SiteCallsPageTests.SetSearchKeywordAsync</c>: <see cref="ILocator.SelectOptionAsync"/>
/// already raises a <c>change</c>, and <see cref="ILocator.FillAsync"/> only fires
/// <c>input</c> — so the subject search box needs an explicit <c>change</c> dispatch so
/// its bound value is committed on the circuit before the Query click, not raced against
/// the click's blur side effect.
/// </summary>
private static async Task SetSubjectKeywordAsync(IPage page, string keyword)
{
var search = page.Locator("#no-search");
await search.FillAsync(keyword);
await search.DispatchEventAsync("change");
}
/// <summary>
/// Seeds a Parked notification, navigates to the report, narrows to it (status=Parked +
/// subject keyword), and returns the row locator once it is visible.
/// </summary>
private async Task<(IPage Page, ILocator Row)> SeedAndLocateParkedRowAsync(
Guid notificationId, string listNameMarker, string subject)
{
await NotificationDataSeeder.InsertParkedNotificationAsync(
notificationId: notificationId,
listNameMarker: listNameMarker,
subject: subject,
sourceSite: "site-a",
retryCount: 3);
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 seeded row: Parked status (so only Retry/Discard-bearing rows
// render) plus the unique subject keyword. The status select is a @bind
// commit-on-change, so SelectOptionAsync's own change event commits it; the
// subject search box needs the explicit change dispatch.
await page.Locator("#no-status").SelectOptionAsync("Parked");
await SetSubjectKeywordAsync(page, subject);
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator("tbody tr", new() { HasText = subject });
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
return (page, row);
}
[SkippableFact]
public async Task Retry_ParkedNotification_ShowsOutcomeToast()
{
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var marker = $"zztest-notif-retry-{runId}";
var notificationId = Guid.NewGuid();
var subject = $"zztest retry {runId}";
try
{
var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);
// The Retry button is only rendered for Parked rows (btn-outline-success).
await Assertions.Expect(row.Locator("button.btn.btn-outline-success.btn-sm")).ToBeVisibleAsync();
await row.Locator("button.btn.btn-outline-success.btn-sm").ClickAsync();
// Confirm the action — non-danger footer button labelled "Confirm".
var confirmButton = page.Locator(".modal-footer .btn-primary");
await Assertions.Expect(confirmButton).ToBeVisibleAsync();
await Assertions.Expect(confirmButton).ToHaveTextAsync("Confirm");
await confirmButton.ClickAsync();
// The retry resolves purely against the central row (no site relay), so a
// single success toast appears. We assert exactly one toast (the single-toast
// contract), tolerant of the exact outcome text. The wait is generous (15s)
// and the toast auto-dismisses 5s after it appears, so we use a single
// web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
}
finally
{
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
[SkippableFact]
public async Task Discard_ParkedNotification_ShowsOutcomeToast()
{
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var marker = $"zztest-notif-discard-{runId}";
var notificationId = Guid.NewGuid();
var subject = $"zztest discard {runId}";
try
{
var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);
// The Discard button is only rendered for Parked rows (btn-outline-danger).
await Assertions.Expect(row.Locator("button.btn.btn-outline-danger.btn-sm")).ToBeVisibleAsync();
await row.Locator("button.btn.btn-outline-danger.btn-sm").ClickAsync();
// Confirm the action — danger footer button labelled "Delete" (the discard
// dialog opens with danger: true).
var deleteButton = page.Locator(".modal-footer .btn-danger");
await Assertions.Expect(deleteButton).ToBeVisibleAsync();
await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
await deleteButton.ClickAsync();
// The discard moves the central row to Discarded and surfaces a single success
// toast. Same single-toast / outcome-tolerant assertion as Retry: a single
// web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
}
finally
{
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
/// <summary>
/// Combined-filter narrowing: two Parked rows share one <c>ListName</c> marker but carry
/// distinct subjects. Applying the exact-match list filter (the marker) plus
/// status=Parked plus the subject keyword for only ONE of the two rows must surface that
/// row and exclude its sibling. This proves the filters compose (AND-narrowing) rather
/// than widening — the marker isolates the pair from ambient cluster rows, and the
/// subject keyword discriminates between them.
/// </summary>
[SkippableFact]
public async Task FilterCombination_StatusPlusList_NarrowsToMatch()
{
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var marker = $"zztest-notif-wave4-{runId}";
// Two Parked rows under the SAME ListName marker, distinct subjects.
await NotificationDataSeeder.InsertParkedNotificationAsync(
Guid.NewGuid(), marker, "wave4-alpha", "site-a");
await NotificationDataSeeder.InsertParkedNotificationAsync(
Guid.NewGuid(), marker, "wave4-beta", "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();
// #no-list is an exact-match text input bound @bind (commit-on-change), so
// FillAsync (which only fires `input`) must be followed by an explicit
// `change` dispatch to commit the bound value before the Query roundtrip —
// same rationale as SetSubjectKeywordAsync for the subject box.
await page.Locator("#no-list").FillAsync(marker);
await page.Locator("#no-list").DispatchEventAsync("change");
// Status select commits on its own SelectOptionAsync change event.
await page.Locator("#no-status").SelectOptionAsync("Parked");
// Subject keyword for ONLY the alpha row.
await SetSubjectKeywordAsync(page, "wave4-alpha");
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The combined filters narrow to alpha: alpha visible, beta absent.
var alphaRow = page.Locator("tbody tr", new() { HasText = "wave4-alpha" });
await Assertions.Expect(alphaRow).ToBeVisibleAsync(new() { Timeout = 15_000 });
await Assertions.Expect(
page.Locator("tbody tr").Filter(new() { HasText = "wave4-beta" }))
.ToHaveCountAsync(0);
}
finally
{
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
/// <summary>
/// The row detail modal opens on a DOUBLE-CLICK of a non-Actions row cell (there is no
/// dedicated open button) and closes via the header X and the footer Close button. Both
/// close affordances are wired to the same <c>CloseDetail</c> handler; this exercises
/// each path independently (open → X-close → reopen → footer-close).
/// </summary>
[SkippableFact]
public async Task DetailModal_OpenOnDblClick_CloseViaXAndFooter()
{
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var marker = $"zztest-notif-wave4-{runId}";
await NotificationDataSeeder.InsertParkedNotificationAsync(
Guid.NewGuid(), marker, "wave4-detail", "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 seeded row by its exact ListName marker (+ Parked status).
await page.Locator("#no-list").FillAsync(marker);
await page.Locator("#no-list").DispatchEventAsync("change");
await page.Locator("#no-status").SelectOptionAsync("Parked");
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator("tbody tr").Filter(new() { HasText = "wave4-detail" });
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
// Open the modal: double-click the SUBJECT cell (a non-Actions cell). The
// Actions cell carries @ondblclick:stopPropagation, so double-clicking it would
// not open the modal — the subject cell is the correct target.
await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();
// Scope the title to the open modal — the page also renders Retry/Discard
// confirm dialogs that carry their own .modal-title.
await Assertions.Expect(page.Locator(".modal.show.d-block .modal-title")).ToContainTextAsync("Notification Detail");
// Close via the header X.
await page.ClickAsync("button.btn-close[aria-label='Close']");
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);
// Re-open (double-click the subject cell again).
await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();
// Close via the footer Close button.
await page.ClickAsync(".modal-footer button.btn-outline-secondary:has-text('Close')");
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);
// Escape-to-close is NOT wired on the Notification detail modal (no keydown handler); covering the X and footer-Close paths that ARE wired (backdrop also closes but is omitted for brevity).
}
finally
{
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}";
try
{
// Genuinely-stuck row: Retrying + back-dated 15 min (> the 10-min StuckAgeThreshold).
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
await NotificationDataSeeder.InsertNotificationAsync(
Guid.NewGuid(), marker, "wave4-stuck",
status: "Retrying", createdAt: DateTimeOffset.UtcNow.AddMinutes(-15),
sourceSite: "site-a");
// Non-stuck row: Parked @ now (Parked is a terminal status → never stuck, regardless of age).
await NotificationDataSeeder.InsertParkedNotificationAsync(
Guid.NewGuid(), marker, "wave4-fresh", "site-a");
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}";
try
{
// 51 Parked rows under one marker → 2 pages (50 + 1); the pager renders (> 50).
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
for (int i = 0; i < 51; i++)
{
await NotificationDataSeeder.InsertParkedNotificationAsync(
Guid.NewGuid(), marker, $"wave4-page-{i:D2}", "site-a");
}
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);
}
}
}