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 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); } } }