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

173 lines
8.1 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 the assertion must
// catch it inside that window.
var toast = page.Locator(".toast");
await Assertions.Expect(toast).ToBeVisibleAsync(new() { Timeout = 15_000 });
Assert.Equal(1, await toast.CountAsync());
}
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.
var toast = page.Locator(".toast");
await Assertions.Expect(toast).ToBeVisibleAsync(new() { Timeout = 15_000 });
Assert.Equal(1, await toast.CountAsync());
}
finally
{
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
}
}
}