test(e2e): cover notification retry/discard + parked-messages query
This commit is contained in:
+67
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end render / no-hang guard for the central Parked Messages page
|
||||
/// (<c>/monitoring/parked-messages</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this is a render guard and NOT a mutation test (unlike
|
||||
/// <see cref="Notifications.NotificationActionTests"/>):</b> parked store-and-forward
|
||||
/// messages live in the SITE's local SQLite buffer, not in central MS SQL. The page
|
||||
/// resolves them by relaying a <c>ParkedMessageQueryRequest</c> to the owning site over
|
||||
/// the cluster (an Akka Ask answered by the site's S&F singleton). There is no central
|
||||
/// table to seed — a directly-INSERTed central row cannot produce a parked S&F message —
|
||||
/// so this test cannot deterministically seed a row to act on. Instead it asserts the
|
||||
/// singleton-backed query <em>resolves</em> (renders the results table or the empty-state
|
||||
/// card) within a generous window rather than hanging on the cross-cluster Ask — the
|
||||
/// regression class this guards against. Empty results are tolerated.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated on <see cref="ClusterAvailability"/> via <c>Skip.IfNot</c>: when the cluster is
|
||||
/// unreachable the fact reports as Skipped (not Failed), matching the established suite
|
||||
/// idiom. The query relays to a live site, so the cluster (not just MSSQL) must be up.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class ParkedMessagesTests
|
||||
{
|
||||
private const string ParkedMessagesUrl = "/monitoring/parked-messages";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public ParkedMessagesTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ParkedMessages_QueryForSite_RendersWithoutHang()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ParkedMessagesUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Parked Messages')")).ToBeVisibleAsync();
|
||||
|
||||
// Select site-a — the <option> value is the SiteIdentifier "site-a". The select is
|
||||
// an @onchange handler that, on a non-empty selection, kicks off the query itself;
|
||||
// SelectOptionAsync raises the change event so the query fires. Click Query as well
|
||||
// to be explicit (the button is enabled once a site is selected).
|
||||
await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')").ClickAsync();
|
||||
|
||||
// The singleton-backed query resolves to EITHER the results table or the empty-state
|
||||
// card. Web-first assertion with a generous timeout (20s) — the relay round-trips to
|
||||
// the site over the cluster, and the regression this guards is the query hanging
|
||||
// (leaving the page stuck on "Loading…"). Either terminal state proves it resolved.
|
||||
var resolved = page.Locator("table.parked-table, div.card-body:has-text('No parked messages')");
|
||||
await Assertions.Expect(resolved.First).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests
|
||||
/// (Notification Outbox #21).
|
||||
///
|
||||
/// <para>
|
||||
/// The Notification Report page reads the central <c>Notifications</c> table through the
|
||||
/// <c>NotificationOutboxActor</c> singleton. Its query path
|
||||
/// (<c>NotificationOutboxQueryRequest</c> → <c>NotificationOutboxRepository.QueryAsync</c>)
|
||||
/// is a pure read-from-table projection with NO default time window — a row INSERTed
|
||||
/// directly into <c>Notifications</c> surfaces on the page exactly as a site-ingested row
|
||||
/// would. The actor's manual Retry / Discard handlers
|
||||
/// (<c>RetryNotificationRequest</c> / <c>DiscardNotificationRequest</c>) likewise act
|
||||
/// purely on the central row (load by id, flip <c>Status</c>, persist) — there is no
|
||||
/// site relay on this path — so a directly-seeded <c>Parked</c> row is genuinely
|
||||
/// retryable/discardable from central. This mirrors <see cref="SiteCalls.SiteCallDataSeeder"/>:
|
||||
/// each test inserts its own row at setup and best-effort deletes it at teardown, keeping
|
||||
/// the suite self-contained without touching <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Rows are tagged with a unique <c>ListName</c> marker derived from the test name + a GUID
|
||||
/// so the teardown <c>DELETE</c> never touches rows the cluster itself produced.
|
||||
/// <c>CreatedAt</c>/<c>SiteEnqueuedAt</c> are pinned to "now" so the page's default
|
||||
/// (unconstrained) query window sees the row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class NotificationDataSeeder
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
|
||||
|
||||
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved from
|
||||
/// <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set, otherwise the local docker dev defaults.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single <c>Parked</c> row into the central <c>Notifications</c> table.
|
||||
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
|
||||
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
|
||||
/// the unique <paramref name="listNameMarker"/> so the test can filter to it and the
|
||||
/// teardown can delete by it. <c>Status</c> is fixed to <c>Parked</c> — the only status
|
||||
/// from which the page exposes Retry/Discard. Timestamps are pinned to "now" so the
|
||||
/// page's default unconstrained query window sees the row.
|
||||
/// </summary>
|
||||
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
|
||||
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
|
||||
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
|
||||
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
|
||||
/// <param name="retryCount">Retry count to display on the row.</param>
|
||||
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public static async Task InsertParkedNotificationAsync(
|
||||
Guid notificationId,
|
||||
string listNameMarker,
|
||||
string subject,
|
||||
string sourceSite,
|
||||
int retryCount = 3,
|
||||
string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// NotificationId is the varchar(64) primary key; Type/Status are stored as
|
||||
// varchar(32) (HasConversion<string>()). All NOT NULL columns are supplied;
|
||||
// the nullable provenance columns (SourceNode, OriginExecutionId, …) are left
|
||||
// to default to NULL, which the page renders as an em-dash.
|
||||
const string sql = @"
|
||||
INSERT INTO [Notifications]
|
||||
([NotificationId], [Type], [ListName], [Subject], [Body], [Status], [RetryCount],
|
||||
[LastError], [SourceSiteId], [SiteEnqueuedAt], [CreatedAt])
|
||||
VALUES
|
||||
(@id, @type, @listName, @subject, @body, @status, @retryCount,
|
||||
@lastError, @sourceSite, @siteEnqueuedAt, @createdAt);";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("@id", notificationId.ToString());
|
||||
cmd.Parameters.AddWithValue("@type", "Email");
|
||||
cmd.Parameters.AddWithValue("@listName", listNameMarker);
|
||||
cmd.Parameters.AddWithValue("@subject", subject);
|
||||
cmd.Parameters.AddWithValue("@body", "Seeded notification body for Playwright E2E.");
|
||||
cmd.Parameters.AddWithValue("@status", "Parked");
|
||||
cmd.Parameters.AddWithValue("@retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
|
||||
cmd.Parameters.AddWithValue("@siteEnqueuedAt", now);
|
||||
cmd.Parameters.AddWithValue("@createdAt", now);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>Notifications</c> row whose <c>ListName</c>
|
||||
/// equals <paramref name="listNameMarker"/>. Swallows all errors — the marker carries a
|
||||
/// per-run GUID so the rows are unique to this test run. A Retry that flips the row back
|
||||
/// to <c>Pending</c> (and a subsequent dispatch sweep) does not change the <c>ListName</c>,
|
||||
/// so the marker still matches whatever terminal state the row ends in.
|
||||
/// </summary>
|
||||
public static async Task DeleteByMarkerAsync(string listNameMarker, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM [Notifications] WHERE [ListName] = @listName";
|
||||
cmd.Parameters.AddWithValue("@listName", listNameMarker);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — the marker carries a GUID so the rows are unique to this test
|
||||
// run and won't collide on the next pass.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe whether the configuration DB is reachable. Tests gate their per-test setup on
|
||||
/// this so a downed cluster surfaces a clear message rather than an opaque
|
||||
/// <see cref="SqlException"/>.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user