diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs
new file mode 100644
index 00000000..d5649b9a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesTests.cs
@@ -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;
+
+///
+/// End-to-end render / no-hang guard for the central Parked Messages page
+/// (/monitoring/parked-messages ).
+///
+///
+/// Why this is a render guard and NOT a mutation test (unlike
+/// ): 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 ParkedMessageQueryRequest 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 resolves (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.
+///
+///
+///
+/// Gated on via Skip.IfNot : 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.
+///
+///
+[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 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 });
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
new file mode 100644
index 00000000..38aace3a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs
@@ -0,0 +1,172 @@
+using Microsoft.Playwright;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
+
+///
+/// End-to-end coverage for the central Notification Report page's Retry / Discard
+/// actions on parked notifications (Notification Outbox #21).
+///
+///
+/// Each test seeds its own Parked row directly into the running cluster's
+/// configuration database via , exercises the UI
+/// through Playwright, then best-effort deletes the row by its unique ListName
+/// marker. The Notification Report page reads the Notifications table through the
+/// NotificationOutboxActor 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 Status → persist) with NO site
+/// relay, so a directly-seeded Parked row is genuinely retryable / discardable from
+/// central and the action's success toast (ToastNotification.ShowSuccess ) appears.
+/// This is therefore a real mutating action test, not merely a render guard.
+///
+///
+///
+/// The DB-seeding tests are + Skip.IfNot :
+/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), matching
+/// the established SiteCallsPageTests idiom.
+///
+///
+[Collection("Playwright")]
+public class NotificationActionTests
+{
+ private const string NotificationReportUrl = "/notifications/report";
+
+ private readonly PlaywrightFixture _fixture;
+
+ public NotificationActionTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ /// Skip reason shared by the DB-seeding tests when MSSQL is down.
+ 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.";
+
+ ///
+ /// Commits a @bind (commit-on-change ) form control to the server as its
+ /// own discrete circuit message before the caller clicks Query. Same rationale as
+ /// SiteCallsPageTests.SetSearchKeywordAsync :
+ /// already raises a change , and only fires
+ /// input — so the subject search box needs an explicit change dispatch so
+ /// its bound value is committed on the circuit before the Query click, not raced against
+ /// the click's blur side effect.
+ ///
+ private static async Task SetSubjectKeywordAsync(IPage page, string keyword)
+ {
+ var search = page.Locator("#no-search");
+ await search.FillAsync(keyword);
+ await search.DispatchEventAsync("change");
+ }
+
+ ///
+ /// Seeds a Parked notification, navigates to the report, narrows to it (status=Parked +
+ /// subject keyword), and returns the row locator once it is visible.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs
new file mode 100644
index 00000000..4c22f61d
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs
@@ -0,0 +1,152 @@
+using Microsoft.Data.SqlClient;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
+
+///
+/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests
+/// (Notification Outbox #21).
+///
+///
+/// The Notification Report page reads the central Notifications table through the
+/// NotificationOutboxActor singleton. Its query path
+/// (NotificationOutboxQueryRequest → NotificationOutboxRepository.QueryAsync )
+/// is a pure read-from-table projection with NO default time window — a row INSERTed
+/// directly into Notifications surfaces on the page exactly as a site-ingested row
+/// would. The actor's manual Retry / Discard handlers
+/// (RetryNotificationRequest / DiscardNotificationRequest ) likewise act
+/// purely on the central row (load by id, flip Status , persist) — there is no
+/// site relay on this path — so a directly-seeded Parked row is genuinely
+/// retryable/discardable from central. This mirrors :
+/// each test inserts its own row at setup and best-effort deletes it at teardown, keeping
+/// the suite self-contained without touching infra/mssql/seed-config.sql .
+///
+///
+///
+/// Rows are tagged with a unique ListName marker derived from the test name + a GUID
+/// so the teardown DELETE never touches rows the cluster itself produced.
+/// CreatedAt /SiteEnqueuedAt are pinned to "now" so the page's default
+/// (unconstrained) query window sees the row.
+///
+///
+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";
+
+ ///
+ /// Connection string for the running cluster's configuration DB. Resolved from
+ /// SCADABRIDGE_PLAYWRIGHT_DB when set, otherwise the local docker dev defaults.
+ ///
+ public static string ConnectionString
+ {
+ get
+ {
+ var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
+ return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
+ }
+ }
+
+ ///
+ /// Inserts a single Parked row into the central Notifications table.
+ /// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
+ /// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
+ /// the unique so the test can filter to it and the
+ /// teardown can delete by it. Status is fixed to Parked — 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.
+ ///
+ /// GUID primary key (stored as its 36-char string form).
+ /// Unique per-run marker stored in ListName .
+ /// Subject text (searchable via the page's subject keyword box).
+ /// Originating site identifier (e.g. site-a ).
+ /// Retry count to display on the row.
+ /// Optional last-error text shown beneath the subject.
+ /// Cancellation token.
+ 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()). 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);
+ }
+
+ ///
+ /// Best-effort cleanup. Deletes every Notifications row whose ListName
+ /// equals . 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 Pending (and a subsequent dispatch sweep) does not change the ListName ,
+ /// so the marker still matches whatever terminal state the row ends in.
+ ///
+ 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.
+ }
+ }
+
+ ///
+ /// 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
+ /// .
+ ///
+ public static async Task IsAvailableAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync(ct);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}