diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs
new file mode 100644
index 00000000..b988d51d
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs
@@ -0,0 +1,176 @@
+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. The page's StartEdit handler loads the stored credential into
+/// the form's _credentials field (_credentials = smtp.Credentials;), so
+/// re-saving an Edit form with every field untouched rewrites the SAME stored values — a
+/// true no-op. The fact only proceeds when a config already exists (probed via the CLI);
+/// it snapshots Host/Port/AuthType/TLS/From as belt-and-braces and best-effort restores
+/// them afterwards. When no config exists there is nothing to edit, so it skips.
+///
+///
+[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();
+
+ var cfgShown = await page.Locator("text=(stored)").IsVisibleAsync()
+ || await page.Locator("text=(not set)").IsVisibleAsync();
+ var emptyShown = await page.Locator("text=No SMTP configuration set.").IsVisibleAsync();
+ Assert.True(cfgShown || emptyShown, "Expected an SMTP config card or the empty-state.");
+ }
+
+ ///
+ /// Re-saves an existing SMTP config's Edit form with NOTHING changed and asserts the
+ /// 'SMTP configuration saved.' toast appears.
+ ///
+ ///
+ /// This is a true no-op only because the page's StartEdit handler loads the stored
+ /// credential into the form's password field (_credentials = smtp.Credentials;) —
+ /// so an untouched Save rewrites the SAME secret, not an empty one. As belt-and-braces the
+ /// fact snapshots Host/Port/AuthType/TLS/From via the CLI and best-effort restores them
+ /// afterwards. 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 restore).
+ ///
+ ///
+ [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.
+ 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 restore. Validation gate is covered by " +
+ "SmtpForm_MissingRequired_ShowsInlineError.");
+
+ // Snapshot the first config (belt-and-braces restore target).
+ var first = configs[0];
+ var snapHost = first.GetProperty("host").GetString();
+
+ 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 pre-fills every field (including the
+ // stored credential), so re-saving untouched is a true no-op.
+ 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.
+ await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
+
+ await Assertions
+ .Expect(page.Locator(".toast:has-text('SMTP configuration saved.')"))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+
+ // Belt-and-braces: best-effort CLI restore of the snapshotted core fields. The no-op
+ // save should already have left them identical; this guards against any drift.
+ if (!string.IsNullOrEmpty(snapHost))
+ {
+ try
+ {
+ await CliRunner.RunAsync("notification", "smtp", "update", "--host", snapHost);
+ }
+ catch
+ {
+ // Best-effort restore — the UI no-op save is the primary guarantee that the
+ // config is unchanged; do not fail the test on a restore hiccup.
+ }
+ }
+ }
+}