293 lines
14 KiB
C#
293 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|