Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs
T

177 lines
8.4 KiB
C#

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;
/// <summary>
/// End-to-end coverage for the SMTP Configuration page (<c>/notifications/smtp</c>,
/// <c>RequireAdmin</c> — the test user <c>multi-role</c> has Admin).
///
/// <para>
/// SMTP config is SHARED central state with NO delete verb (neither UI nor CLI). The
/// deterministic, always-safe assertions here MUTATE NOTHING:
/// <list type="bullet">
/// <item><see cref="SmtpForm_MissingRequired_ShowsInlineError"/> opens the form, clears
/// the required Host field and clicks Save — the page's <c>Save</c> handler returns at the
/// required-field guard BEFORE persisting, so nothing is written. Then it cancels.</item>
/// <item><see cref="SmtpPage_RendersConfigOrEmptyState"/> is pure-read render verification.</item>
/// </list>
/// </para>
///
/// <para>
/// <see cref="SmtpEdit_NoopSave_ShowsSavedToast"/> is the only mutating fact, and is
/// gated on safety. The page's <c>StartEdit</c> handler loads the stored credential into
/// the form's <c>_credentials</c> field (<c>_credentials = smtp.Credentials;</c>), 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.
/// </para>
/// </summary>
[Collection("Playwright")]
public class SmtpConfigTests
{
private const string SmtpUrl = "/notifications/smtp";
private readonly PlaywrightFixture _pw;
public SmtpConfigTests(PlaywrightFixture pw)
{
_pw = pw;
}
/// <summary>
/// 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 <c>Save</c> handler returns at this guard BEFORE persisting, so this fact
/// mutates nothing. Then it cancels and asserts the form closed.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
/// <summary>
/// Re-saves an existing SMTP config's Edit form with NOTHING changed and asserts the
/// 'SMTP configuration saved.' toast appears.
///
/// <para>
/// This is a true no-op only because the page's <c>StartEdit</c> handler loads the stored
/// credential into the form's password field (<c>_credentials = smtp.Credentials;</c>) —
/// 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).
/// </para>
/// </summary>
[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.
}
}
}
}