310 lines
15 KiB
C#
310 lines
15 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
|
|
|
[Collection("Playwright")]
|
|
public class SiteCrudTests
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public SiteCrudTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SitesPage_ShowsSiteManagement()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Sites.razor renders the management page as a heading plus site cards
|
|
// (not an HTML table) and an always-present "Add Site" action.
|
|
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
|
await Expect(page.Locator("button:has-text('Add Site')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddSiteButton_NavigatesToCreatePage()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Add Site')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
|
|
var inputCount = await page.Locator("input").CountAsync();
|
|
Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_BackButton_ReturnsToList()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Back')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_CancelButton_ReturnsToList()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Cancel')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_HasNodeSubsections()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
|
|
await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_SaveWithoutName_ShowsError()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Save')");
|
|
|
|
// Should stay on create page with validation error
|
|
Assert.Contains("/admin/sites/create", page.Url);
|
|
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full create → edit → delete round-trip for a site via the Central UI.
|
|
/// Uses CliRunner for best-effort teardown so no zztest-* site leaks on failure.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task CreateEditDelete_Site_RoundTrips()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var hex = Guid.NewGuid().ToString("N")[..8];
|
|
var ident = $"zztest-{hex}";
|
|
var name = $"zztest-site-{hex}";
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill Identifier — label-anchored input (Node A/B share the same Akka/gRPC
|
|
// placeholder text so we select the Akka inputs by placeholder index below).
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e");
|
|
|
|
// Node A and Node B share identical placeholder text; select by index.
|
|
// Index 0 = Node A Akka, Index 1 = Node B Akka.
|
|
var akkaInputs = page.Locator("input[placeholder='akka.tcp://scadabridge@host:port/user/site-communication']");
|
|
await akkaInputs.Nth(0).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
|
await akkaInputs.Nth(1).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
|
|
|
// Index 0 = Node A gRPC, Index 1 = Node B gRPC.
|
|
var grpcInputs = page.Locator("input[placeholder='http://host:8083']");
|
|
await grpcInputs.Nth(0).FillAsync("http://zz:8083");
|
|
await grpcInputs.Nth(1).FillAsync("http://zz:8083");
|
|
|
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
|
|
|
// Wait for Blazor enhanced navigation back to the list page.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The new site card must be visible.
|
|
var card = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(card).ToBeVisibleAsync();
|
|
|
|
// ── EDIT ──────────────────────────────────────────────────────────────────
|
|
// Scope the Edit button to the card to avoid strict-mode violations.
|
|
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Update the description and save.
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e-edited");
|
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Card must still be present after the edit.
|
|
await Assertions.Expect(page.Locator("div.card", new() { HasText = name })).ToBeVisibleAsync();
|
|
|
|
// ── DELETE ────────────────────────────────────────────────────────────────
|
|
// Re-locate the card after navigation; scope kebab+delete to the .dropdown
|
|
// container inside the card to be defensive against strict-mode violations.
|
|
var cardAfterEdit = page.Locator("div.card", new() { HasText = name });
|
|
var cardDropdown = cardAfterEdit.Locator(".dropdown");
|
|
var kebab = cardDropdown.Locator("button[aria-label^='More actions']");
|
|
await kebab.ClickAsync();
|
|
|
|
// Click Delete in the now-open dropdown — scoped to the .dropdown container.
|
|
var deleteBtn = cardDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
|
await deleteBtn.ClickAsync();
|
|
|
|
// Confirm the global danger dialog.
|
|
await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
|
|
await page.ClickAsync(".modal-footer .btn-danger");
|
|
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The card must be gone.
|
|
await Assertions.Expect(page.Locator("div.card", new() { HasText = name }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort teardown: delete the site via CLI in case the UI path failed
|
|
// mid-way. The happy path already deleted it via the UI, so ResolveSiteIdAsync
|
|
// will throw (no matching site) and the inner catch swallows it.
|
|
try
|
|
{
|
|
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
|
}
|
|
catch
|
|
{
|
|
// Site already deleted (happy path) or cluster unreachable — ignore.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creating a site whose Identifier collides with the always-present seeded
|
|
/// <c>site-a</c> must fail at save and persist nothing. SiteForm.SaveSite()
|
|
/// catches the duplicate DbUpdateException → <c>_formError = "Save failed: …"</c>
|
|
/// (a raw EF message) and stays on /create. The Name is deliberately distinct so
|
|
/// only the SiteIdentifier unique index can trip (not the Name unique index).
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task Create_DuplicateIdentifier_ShowsSaveFailedError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// Confirm the collision target exists (site-a is a core cluster seed; throws if absent).
|
|
await CliRunner.ResolveSiteIdAsync("site-a");
|
|
|
|
// Distinct name so the failure can only be the SiteIdentifier unique index.
|
|
var distinctName = $"zztest-dup-{Guid.NewGuid():N}"[..16];
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Identifier collides with the seeded site-a; Name is distinct. Node addresses blank.
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync("site-a");
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(distinctName);
|
|
|
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
|
|
|
// The inline error surface must report the failed save and we must stay on /create.
|
|
// The full message is a raw DbUpdateException — assert only the "Save failed" prefix.
|
|
await Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed");
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/sites/create"));
|
|
|
|
// No teardown: the SiteIdentifier unique index guarantees the create persisted nothing.
|
|
// A spuriously-persisted row would carry identifier "site-a" (not distinctName), and that
|
|
// cannot be auto-deleted without destroying the real seed — so there is nothing safe to sweep.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Editing a site and clicking Cancel must discard the change. SiteForm's Cancel
|
|
/// (like Back) calls GoBack() → /admin/sites with NO dirty-check and NO persistence,
|
|
/// so re-opening the edit must show the ORIGINAL (empty) Description.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task EditCancel_DiscardsChanges()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var hex = Guid.NewGuid().ToString("N")[..8];
|
|
var ident = $"zztest-{hex}";
|
|
var name = $"zztest-site-{hex}";
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE (mirror the round-trip test's create steps) ──────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
|
|
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// ── EDIT → change Description → CANCEL ──────────────────────────────────────
|
|
var card = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(card).ToBeVisibleAsync();
|
|
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm")
|
|
.FillAsync("zztest-CANCELLED-EDIT");
|
|
|
|
await page.ClickAsync("button:has-text('Cancel')");
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// ── RE-OPEN EDIT → Description must be the ORIGINAL (empty) ──────────────────
|
|
var cardAfterCancel = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(cardAfterCancel).ToBeVisibleAsync();
|
|
await cardAfterCancel.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var descInput = page.Locator("label:has-text('Description') + input.form-control.form-control-sm");
|
|
await Expect(descInput).ToHaveValueAsync("");
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort teardown: delete the site via CLI.
|
|
try
|
|
{
|
|
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
|
}
|
|
catch
|
|
{
|
|
// Already gone or cluster unreachable — ignore.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static ILocatorAssertions Expect(ILocator locator) =>
|
|
Assertions.Expect(locator);
|
|
}
|