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(); // Make the row locator strict-mode-safe: assert exactly one match before acting. await Assertions.Expect(listRow).ToHaveCountAsync(1, new() { Timeout = 10_000 }); // ── ADD RECIPIENT ───────────────────────────────────────────────────────── await listRow.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync(); // The Edit click triggers Blazor enhanced navigation (a SignalR round-trip that // loads the edit form's data); wait for it to settle before asserting the heading. await page.WaitForLoadStateAsync(LoadState.NetworkIdle); 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 }); // Make the row locator strict-mode-safe: assert exactly one match before acting. await Assertions.Expect(listRowAgain).ToHaveCountAsync(1, new() { Timeout = 10_000 }); 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(); // Assert the success toast in one web-first check — count + body text together — so the // second assertion can't race the toast's 5s auto-dismiss. await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." })) .ToHaveCountAsync(1, new() { Timeout = 15_000 }); 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(); await Assertions.Expect(page).ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/create")); } }