using System.Text.Json; using Microsoft.Playwright; using Xunit; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications; /// /// End-to-end coverage for the SMTP Configuration page (/notifications/smtp, /// RequireAdmin — the test user multi-role has Admin). /// /// /// SMTP config is SHARED central state with NO delete verb (neither UI nor CLI). The /// deterministic, always-safe assertions here MUTATE NOTHING: /// /// opens the form, clears /// the required Host field and clicks Save — the page's Save handler returns at the /// required-field guard BEFORE persisting, so nothing is written. Then it cancels. /// is pure-read render verification. /// /// /// /// /// is the only mutating fact, and is /// gated on safety. Its sole safety mechanism is that the Save is a genuine no-op: the /// page's StartEdit handler reloads EVERY field into the form /// (_host/_port/_authType/_tlsMode/_credentials/_fromAddress, including /// _credentials = smtp.Credentials;), so saving an untouched Edit form rewrites the /// SAME stored values — zero net change. No CLI restore is used: the masked credential is /// never exposed (un-snapshottable), and the save changes nothing, so a restore would add /// risk without benefit. The fact only proceeds when a config already exists (probed via /// the CLI); when none exists there is nothing to edit, so it skips — we never CREATE a /// config we could not delete (SMTP config has no delete verb). /// /// [Collection("Playwright")] public class SmtpConfigTests { private const string SmtpUrl = "/notifications/smtp"; private readonly PlaywrightFixture _pw; public SmtpConfigTests(PlaywrightFixture pw) { _pw = pw; } /// /// Opens the SMTP form (Add when empty, else Edit), clears the required Host field and /// clicks Save, asserting the inline 'Host and From Address are required.' error appears. /// The page's Save handler returns at this guard BEFORE persisting, so this fact /// mutates nothing. Then it cancels and asserts the form closed. /// [SkippableFact] public async Task SmtpForm_MissingRequired_ShowsInlineError() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var page = await _pw.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync(); // Open the form. Prefer the empty-state Add button (Host starts empty); otherwise // edit the first existing card (Host pre-filled — we clear it below). var addBtn = page.Locator("button.btn.btn-primary.btn-sm:has-text('Add SMTP configuration')"); if (await addBtn.IsVisibleAsync()) { await addBtn.ClickAsync(); } else { await page.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").First.ClickAsync(); } // Ensure Host is EMPTY. Clearing Host alone trips the required-field guard, which // returns before saving — so nothing is persisted regardless of the other fields. await page.Locator("input[type=text].form-control").First.FillAsync(""); await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); await Assertions .Expect(page.Locator("div.text-danger.small:has-text('Host and From Address are required.')")) .ToBeVisibleAsync(new() { Timeout = 10_000 }); // Cancel and confirm the form closed (Save button gone). Leaves config exactly as found. await page.Locator("button.btn-outline-secondary:has-text('Cancel')").ClickAsync(); await Assertions.Expect(page.Locator("button.btn-success:has-text('Save')")).ToHaveCountAsync(0); await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync(); } /// /// Navigates to the SMTP page and asserts it resolved to a real state — EITHER a config /// card (Credentials value '(stored)' or '(not set)') is present, OR the empty-state /// 'No SMTP configuration set.' text is shown. Pure-read; mutates nothing. /// [SkippableFact] public async Task SmtpPage_RendersConfigOrEmptyState() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var page = await _pw.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync(); // Web-first: assert the page resolved to EITHER a config card (Credentials '(stored)' / // '(not set)') OR the empty-state, with retry so a slow Blazor init does not spuriously // fail. A union locator + single expectation replaces the point-in-time boolean OR. await Assertions.Expect( page.GetByText(new System.Text.RegularExpressions.Regex(@"\(stored\)|\(not set\)|No SMTP configuration set\.")) .First ).ToBeVisibleAsync(new() { Timeout = 10_000 }); } /// /// Re-saves an existing SMTP config's Edit form with NOTHING changed and asserts the /// 'SMTP configuration saved.' toast appears. /// /// /// The no-op save is the SOLE safety mechanism, and it is genuinely sufficient: the page's /// StartEdit handler reloads every field into the form /// (_host/_port/_authType/_tlsMode/_credentials/_fromAddress, including /// _credentials = smtp.Credentials;), so an untouched Save rewrites the SAME stored /// values — the credential included — for zero net change. No CLI restore is used: the /// masked credential is never exposed so it could not be snapshotted/restored anyway, and /// the save mutates nothing. When no config exists there is nothing to edit and the fact /// skips (SMTP config has no delete verb, so we never create one we could not delete). /// /// [SkippableFact] public async Task SmtpEdit_NoopSave_ShowsSavedToast() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); // Probe whether a config exists; if not, there is nothing safe to edit. We never // CREATE a config here because SMTP config has no delete verb (we could not clean up). using var listDoc = await CliRunner.RunJsonAsync("notification", "smtp", "list"); var configs = listDoc.RootElement; Skip.If( configs.ValueKind != JsonValueKind.Array || configs.GetArrayLength() == 0, "No SMTP configuration exists to no-op-edit; SMTP config has no delete verb, " + "so we never create one we cannot delete. Validation gate is covered by " + "SmtpForm_MissingRequired_ShowsInlineError."); var page = await _pw.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync(); // Open the first card's Edit form. StartEdit reloads EVERY field (including the stored // credential, _credentials = smtp.Credentials), so re-saving untouched rewrites the // identical values — a true no-op. This is the sole safety mechanism: no CLI restore is // used, because the masked credential is never exposed (un-snapshottable) and the save // changes nothing, so a restore would add risk without benefit. await page.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").First.ClickAsync(); await Assertions.Expect(page.Locator("button.btn-success:has-text('Save')")).ToBeVisibleAsync(); // Change NOTHING; click Save. StartEdit reloaded all fields, so this persists identical // values — zero net change. await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); await Assertions .Expect(page.Locator(".toast", new() { HasText = "SMTP configuration saved." })) .ToHaveCountAsync(1, new() { Timeout = 15_000 }); } }