From b48741f903945c62dea1222788d808c09c44140c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 14:42:45 -0400 Subject: [PATCH] test(playwright): add NotificationList CRUD + recipient + validation coverage (Wave 3) --- .../NotificationListCrudTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs new file mode 100644 index 00000000..2e14d7d2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Playwright; +using Xunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; + +/// +/// Full-UI round-trip coverage for Notification Lists (/notifications/lists). +/// +/// +/// This page requires the Design role; the test user multi-role has it. The +/// happy-path fact drives the entire lifecycle through the UI only — create the list, +/// open it for edit, add a recipient, remove that recipient, then delete the list and +/// assert the confirm-dialog + success-toast + row-gone sequence. A finally block +/// performs best-effort CLI teardown so no zztest-notiflist-* list leaks on failure. +/// The validation fact mutates nothing. +/// +/// +[Collection("Playwright")] +public class NotificationListCrudTests +{ + private const string ListsUrl = "/notifications/lists"; + + private readonly PlaywrightFixture _pw; + + public NotificationListCrudTests(PlaywrightFixture pw) + { + _pw = pw; + } + + /// + /// Drives the complete Notification List lifecycle through the UI: create the list, + /// open it for edit, add a recipient, remove that recipient, then delete the list — + /// asserting the delete confirm dialog, the success toast, and the row disappearing. + /// + [SkippableFact] + public async Task Create_AddRecipient_RemoveRecipient_Delete_RoundTrips() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var name = CliRunner.UniqueName("notiflist"); + const string recipEmail = "zzrec@example.invalid"; + + var page = await _pw.NewAuthenticatedPageAsync(); + + try + { + // ── CREATE ──────────────────────────────────────────────────────────────── + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator("h4:has-text('Add Notification List')")).ToBeVisibleAsync(); + + // The create form has exactly one input (the list name); recipients are edit-only. + await page.Locator("input.form-control").First.FillAsync(name); + await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); + + // On success the form redirects back to the list page (no toast). + await PlaywrightFixture.WaitForPathAsync(page, ListsUrl, excludePath: "/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var listRow = page.Locator("tr").Filter(new() { HasText = name }); + await Assertions.Expect(listRow).ToBeVisibleAsync(); + + // ── ADD RECIPIENT ───────────────────────────────────────────────────────── + await listRow.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync(); + + await Assertions.Expect(page.Locator("h4:has-text('Edit Notification List')")).ToBeVisibleAsync(); + + // The edit page has TWO "Name" text inputs (list name + recipient name). Scope the + // recipient form to the card that contains the email input to disambiguate. + var recipientCard = page.Locator(".card").Filter(new() { Has = page.Locator("input[type=email]") }); + await recipientCard.Locator("input[type=text].form-control").FillAsync("zzrec"); + await recipientCard.Locator("input[type=email]").FillAsync(recipEmail); + await recipientCard.Locator("button.btn-success:has-text('Add')").ClickAsync(); + + var recipientRow = page.Locator("tr").Filter(new() { HasText = recipEmail }); + await Assertions.Expect(recipientRow).ToBeVisibleAsync(); + + // ── REMOVE RECIPIENT ────────────────────────────────────────────────────── + // Direct delete — no confirm dialog. + await recipientRow.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync(); + await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = recipEmail })) + .ToHaveCountAsync(0, new() { Timeout = 10_000 }); + + // ── DELETE LIST ─────────────────────────────────────────────────────────── + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var listRowAgain = page.Locator("tr").Filter(new() { HasText = name }); + await listRowAgain.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync(); + + // Confirm the global danger dialog. + await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync(); + await page.Locator(".modal-footer .btn-danger").ClickAsync(); + + // Success toast appears (auto-dismisses at 5s, so assert promptly) and the row is gone. + await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 }); + await Assertions.Expect(page.Locator(".toast-body:has-text('Deleted.')")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = name })) + .ToHaveCountAsync(0, new() { Timeout = 10_000 }); + } + finally + { + foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name)) + await CliRunner.DeleteNotificationListAsync(id); + } + } + + /// + /// Submits the create form with a blank name and asserts the inline 'Name required.' + /// validation error appears and the page does not navigate away. Mutates nothing. + /// + [SkippableFact] + public async Task CreateForm_EmptyName_ShowsInlineError() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); + + await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name required.')")).ToBeVisibleAsync(); + Assert.Contains("/create", page.Url); + } +}